From 4ef110fd2c501b718344c72d495ad7e16d2bd465 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 30 Dec 2023 23:25:20 +0100 Subject: T5474: establish common file name pattern for XML conf mode commands We will use _ as CLI level divider. The XML definition filename and also the Python helper should match the CLI node. Example: set interfaces ethernet -> interfaces_ethernet.xml.in set interfaces bond -> interfaces_bond.xml.in set service dhcp-server -> service_dhcp-server-xml.in --- src/conf_mode/arp.py | 74 --- src/conf_mode/bcast_relay.py | 111 ---- src/conf_mode/config_mgmt.py | 96 --- src/conf_mode/conntrack.py | 243 ------- src/conf_mode/conntrack_sync.py | 141 ---- src/conf_mode/dhcp_relay.py | 104 --- src/conf_mode/dhcp_server.py | 385 ----------- src/conf_mode/dhcpv6_relay.py | 106 --- src/conf_mode/dhcpv6_server.py | 222 ------- src/conf_mode/dns_dynamic.py | 187 ------ src/conf_mode/dns_forwarding.py | 358 ---------- src/conf_mode/firewall.py | 3 - src/conf_mode/flow_accounting_conf.py | 320 --------- src/conf_mode/host_name.py | 188 ------ src/conf_mode/https.py | 335 ---------- src/conf_mode/igmp_proxy.py | 113 ---- src/conf_mode/intel_qat.py | 106 --- src/conf_mode/interfaces-bonding.py | 294 --------- src/conf_mode/interfaces-bridge.py | 186 ------ src/conf_mode/interfaces-dummy.py | 76 --- src/conf_mode/interfaces-ethernet.py | 400 ----------- src/conf_mode/interfaces-geneve.py | 102 --- src/conf_mode/interfaces-input.py | 70 -- src/conf_mode/interfaces-l2tpv3.py | 112 ---- src/conf_mode/interfaces-loopback.py | 66 -- src/conf_mode/interfaces-macsec.py | 207 ------ src/conf_mode/interfaces-openvpn.py | 732 --------------------- src/conf_mode/interfaces-pppoe.py | 148 ----- src/conf_mode/interfaces-pseudo-ethernet.py | 107 --- src/conf_mode/interfaces-sstpc.py | 145 ---- src/conf_mode/interfaces-tunnel.py | 224 ------- src/conf_mode/interfaces-virtual-ethernet.py | 114 ---- src/conf_mode/interfaces-vti.py | 68 -- src/conf_mode/interfaces-vxlan.py | 236 ------- src/conf_mode/interfaces-wireguard.py | 133 ---- src/conf_mode/interfaces-wireless.py | 275 -------- src/conf_mode/interfaces-wwan.py | 189 ------ src/conf_mode/interfaces_bonding.py | 294 +++++++++ src/conf_mode/interfaces_bridge.py | 186 ++++++ src/conf_mode/interfaces_dummy.py | 76 +++ src/conf_mode/interfaces_ethernet.py | 400 +++++++++++ src/conf_mode/interfaces_geneve.py | 102 +++ src/conf_mode/interfaces_input.py | 70 ++ src/conf_mode/interfaces_l2tpv3.py | 112 ++++ src/conf_mode/interfaces_loopback.py | 66 ++ src/conf_mode/interfaces_macsec.py | 207 ++++++ src/conf_mode/interfaces_openvpn.py | 732 +++++++++++++++++++++ src/conf_mode/interfaces_pppoe.py | 148 +++++ src/conf_mode/interfaces_pseudo-ethernet.py | 107 +++ src/conf_mode/interfaces_sstpc.py | 145 ++++ src/conf_mode/interfaces_tunnel.py | 224 +++++++ src/conf_mode/interfaces_virtual-ethernet.py | 114 ++++ src/conf_mode/interfaces_vti.py | 68 ++ src/conf_mode/interfaces_vxlan.py | 236 +++++++ src/conf_mode/interfaces_wireguard.py | 133 ++++ src/conf_mode/interfaces_wireless.py | 275 ++++++++ src/conf_mode/interfaces_wwan.py | 189 ++++++ src/conf_mode/le_cert.py | 115 ---- src/conf_mode/lldp.py | 123 ---- src/conf_mode/load-balancing-haproxy.py | 169 ----- src/conf_mode/load-balancing-wan.py | 151 ----- src/conf_mode/load-balancing_reverse-proxy.py | 169 +++++ src/conf_mode/load-balancing_wan.py | 151 +++++ src/conf_mode/ntp.py | 136 ---- src/conf_mode/pki.py | 8 +- src/conf_mode/policy-local-route.py | 315 --------- src/conf_mode/policy-route.py | 195 ------ src/conf_mode/policy_local-route.py | 315 +++++++++ src/conf_mode/policy_route.py | 195 ++++++ src/conf_mode/protocols_igmp-proxy.py | 113 ++++ src/conf_mode/protocols_segment-routing.py | 118 ++++ src/conf_mode/protocols_segment_routing.py | 118 ---- src/conf_mode/protocols_static_arp.py | 74 +++ src/conf_mode/salt-minion.py | 118 ---- src/conf_mode/service_broadcast-relay.py | 111 ++++ src/conf_mode/service_config-sync.py | 105 +++ src/conf_mode/service_config_sync.py | 105 --- src/conf_mode/service_conntrack-sync.py | 141 ++++ src/conf_mode/service_dhcp-relay.py | 104 +++ src/conf_mode/service_dhcp-server.py | 385 +++++++++++ src/conf_mode/service_dhcpv6-relay.py | 106 +++ src/conf_mode/service_dhcpv6-server.py | 222 +++++++ src/conf_mode/service_dns_dynamic.py | 187 ++++++ src/conf_mode/service_dns_forwarding.py | 358 ++++++++++ src/conf_mode/service_event-handler.py | 92 +++ src/conf_mode/service_event_handler.py | 92 --- src/conf_mode/service_https.py | 335 ++++++++++ .../service_https_certificates_certbot.py | 114 ++++ src/conf_mode/service_ids_ddos-protection.py | 104 +++ src/conf_mode/service_ids_fastnetmon.py | 104 --- src/conf_mode/service_lldp.py | 123 ++++ src/conf_mode/service_mdns-repeater.py | 146 ---- src/conf_mode/service_mdns_repeater.py | 146 ++++ src/conf_mode/service_ntp.py | 136 ++++ src/conf_mode/service_salt-minion.py | 118 ++++ src/conf_mode/service_snmp.py | 269 ++++++++ src/conf_mode/service_ssh.py | 142 ++++ src/conf_mode/service_tftp-server.py | 142 ++++ src/conf_mode/snmp.py | 269 -------- src/conf_mode/ssh.py | 142 ---- src/conf_mode/system-ip.py | 143 ---- src/conf_mode/system-ipv6.py | 120 ---- src/conf_mode/system-login-banner.py | 107 --- src/conf_mode/system-login.py | 423 ------------ src/conf_mode/system-logs.py | 79 --- src/conf_mode/system-option.py | 159 ----- src/conf_mode/system-proxy.py | 71 -- src/conf_mode/system-syslog.py | 103 --- src/conf_mode/system-timezone.py | 61 -- src/conf_mode/system_acceleration.py | 106 +++ src/conf_mode/system_config-management.py | 96 +++ src/conf_mode/system_conntrack.py | 243 +++++++ src/conf_mode/system_flow-accounting.py | 320 +++++++++ src/conf_mode/system_host-name.py | 188 ++++++ src/conf_mode/system_ip.py | 143 ++++ src/conf_mode/system_ipv6.py | 120 ++++ src/conf_mode/system_login.py | 423 ++++++++++++ src/conf_mode/system_login_banner.py | 107 +++ src/conf_mode/system_logs.py | 79 +++ src/conf_mode/system_option.py | 159 +++++ src/conf_mode/system_proxy.py | 71 ++ src/conf_mode/system_syslog.py | 103 +++ src/conf_mode/system_task-scheduler.py | 153 +++++ src/conf_mode/system_timezone.py | 61 ++ src/conf_mode/system_update-check.py | 93 +++ src/conf_mode/system_update_check.py | 93 --- src/conf_mode/task_scheduler.py | 153 ----- src/conf_mode/tftp_server.py | 142 ---- .../ip-down.d/98-vyos-pppoe-cleanup-nameservers | 1 - .../ppp/ip-up.d/98-vyos-pppoe-setup-nameservers | 1 - src/init/vyos-router | 10 +- src/migration-scripts/https/1-to-2 | 2 +- src/op_mode/connect_disconnect.py | 2 +- src/system/keepalived-fifo.py | 2 +- src/tests/test_task_scheduler.py | 8 +- 135 files changed, 10940 insertions(+), 10946 deletions(-) delete mode 100755 src/conf_mode/arp.py delete mode 100755 src/conf_mode/bcast_relay.py delete mode 100755 src/conf_mode/config_mgmt.py delete mode 100755 src/conf_mode/conntrack.py delete mode 100755 src/conf_mode/conntrack_sync.py delete mode 100755 src/conf_mode/dhcp_relay.py delete mode 100755 src/conf_mode/dhcp_server.py delete mode 100755 src/conf_mode/dhcpv6_relay.py delete mode 100755 src/conf_mode/dhcpv6_server.py delete mode 100755 src/conf_mode/dns_dynamic.py delete mode 100755 src/conf_mode/dns_forwarding.py delete mode 100755 src/conf_mode/flow_accounting_conf.py delete mode 100755 src/conf_mode/host_name.py delete mode 100755 src/conf_mode/https.py delete mode 100755 src/conf_mode/igmp_proxy.py delete mode 100755 src/conf_mode/intel_qat.py delete mode 100755 src/conf_mode/interfaces-bonding.py delete mode 100755 src/conf_mode/interfaces-bridge.py delete mode 100755 src/conf_mode/interfaces-dummy.py delete mode 100755 src/conf_mode/interfaces-ethernet.py delete mode 100755 src/conf_mode/interfaces-geneve.py delete mode 100755 src/conf_mode/interfaces-input.py delete mode 100755 src/conf_mode/interfaces-l2tpv3.py delete mode 100755 src/conf_mode/interfaces-loopback.py delete mode 100755 src/conf_mode/interfaces-macsec.py delete mode 100755 src/conf_mode/interfaces-openvpn.py delete mode 100755 src/conf_mode/interfaces-pppoe.py delete mode 100755 src/conf_mode/interfaces-pseudo-ethernet.py delete mode 100755 src/conf_mode/interfaces-sstpc.py delete mode 100755 src/conf_mode/interfaces-tunnel.py delete mode 100755 src/conf_mode/interfaces-virtual-ethernet.py delete mode 100755 src/conf_mode/interfaces-vti.py delete mode 100755 src/conf_mode/interfaces-vxlan.py delete mode 100755 src/conf_mode/interfaces-wireguard.py delete mode 100755 src/conf_mode/interfaces-wireless.py delete mode 100755 src/conf_mode/interfaces-wwan.py create mode 100755 src/conf_mode/interfaces_bonding.py create mode 100755 src/conf_mode/interfaces_bridge.py create mode 100755 src/conf_mode/interfaces_dummy.py create mode 100755 src/conf_mode/interfaces_ethernet.py create mode 100755 src/conf_mode/interfaces_geneve.py create mode 100755 src/conf_mode/interfaces_input.py create mode 100755 src/conf_mode/interfaces_l2tpv3.py create mode 100755 src/conf_mode/interfaces_loopback.py create mode 100755 src/conf_mode/interfaces_macsec.py create mode 100755 src/conf_mode/interfaces_openvpn.py create mode 100755 src/conf_mode/interfaces_pppoe.py create mode 100755 src/conf_mode/interfaces_pseudo-ethernet.py create mode 100755 src/conf_mode/interfaces_sstpc.py create mode 100755 src/conf_mode/interfaces_tunnel.py create mode 100755 src/conf_mode/interfaces_virtual-ethernet.py create mode 100755 src/conf_mode/interfaces_vti.py create mode 100755 src/conf_mode/interfaces_vxlan.py create mode 100755 src/conf_mode/interfaces_wireguard.py create mode 100755 src/conf_mode/interfaces_wireless.py create mode 100755 src/conf_mode/interfaces_wwan.py delete mode 100755 src/conf_mode/le_cert.py delete mode 100755 src/conf_mode/lldp.py delete mode 100755 src/conf_mode/load-balancing-haproxy.py delete mode 100755 src/conf_mode/load-balancing-wan.py create mode 100755 src/conf_mode/load-balancing_reverse-proxy.py create mode 100755 src/conf_mode/load-balancing_wan.py delete mode 100755 src/conf_mode/ntp.py delete mode 100755 src/conf_mode/policy-local-route.py delete mode 100755 src/conf_mode/policy-route.py create mode 100755 src/conf_mode/policy_local-route.py create mode 100755 src/conf_mode/policy_route.py create mode 100755 src/conf_mode/protocols_igmp-proxy.py create mode 100755 src/conf_mode/protocols_segment-routing.py delete mode 100755 src/conf_mode/protocols_segment_routing.py create mode 100755 src/conf_mode/protocols_static_arp.py delete mode 100755 src/conf_mode/salt-minion.py create mode 100755 src/conf_mode/service_broadcast-relay.py create mode 100755 src/conf_mode/service_config-sync.py delete mode 100755 src/conf_mode/service_config_sync.py create mode 100755 src/conf_mode/service_conntrack-sync.py create mode 100755 src/conf_mode/service_dhcp-relay.py create mode 100755 src/conf_mode/service_dhcp-server.py create mode 100755 src/conf_mode/service_dhcpv6-relay.py create mode 100755 src/conf_mode/service_dhcpv6-server.py create mode 100755 src/conf_mode/service_dns_dynamic.py create mode 100755 src/conf_mode/service_dns_forwarding.py create mode 100755 src/conf_mode/service_event-handler.py delete mode 100755 src/conf_mode/service_event_handler.py create mode 100755 src/conf_mode/service_https.py create mode 100755 src/conf_mode/service_https_certificates_certbot.py create mode 100755 src/conf_mode/service_ids_ddos-protection.py delete mode 100755 src/conf_mode/service_ids_fastnetmon.py create mode 100755 src/conf_mode/service_lldp.py delete mode 100755 src/conf_mode/service_mdns-repeater.py create mode 100755 src/conf_mode/service_mdns_repeater.py create mode 100755 src/conf_mode/service_ntp.py create mode 100755 src/conf_mode/service_salt-minion.py create mode 100755 src/conf_mode/service_snmp.py create mode 100755 src/conf_mode/service_ssh.py create mode 100755 src/conf_mode/service_tftp-server.py delete mode 100755 src/conf_mode/snmp.py delete mode 100755 src/conf_mode/ssh.py delete mode 100755 src/conf_mode/system-ip.py delete mode 100755 src/conf_mode/system-ipv6.py delete mode 100755 src/conf_mode/system-login-banner.py delete mode 100755 src/conf_mode/system-login.py delete mode 100755 src/conf_mode/system-logs.py delete mode 100755 src/conf_mode/system-option.py delete mode 100755 src/conf_mode/system-proxy.py delete mode 100755 src/conf_mode/system-syslog.py delete mode 100755 src/conf_mode/system-timezone.py create mode 100755 src/conf_mode/system_acceleration.py create mode 100755 src/conf_mode/system_config-management.py create mode 100755 src/conf_mode/system_conntrack.py create mode 100755 src/conf_mode/system_flow-accounting.py create mode 100755 src/conf_mode/system_host-name.py create mode 100755 src/conf_mode/system_ip.py create mode 100755 src/conf_mode/system_ipv6.py create mode 100755 src/conf_mode/system_login.py create mode 100755 src/conf_mode/system_login_banner.py create mode 100755 src/conf_mode/system_logs.py create mode 100755 src/conf_mode/system_option.py create mode 100755 src/conf_mode/system_proxy.py create mode 100755 src/conf_mode/system_syslog.py create mode 100755 src/conf_mode/system_task-scheduler.py create mode 100755 src/conf_mode/system_timezone.py create mode 100755 src/conf_mode/system_update-check.py delete mode 100755 src/conf_mode/system_update_check.py delete mode 100755 src/conf_mode/task_scheduler.py delete mode 100755 src/conf_mode/tftp_server.py (limited to 'src') diff --git a/src/conf_mode/arp.py b/src/conf_mode/arp.py deleted file mode 100755 index b141f1141..000000000 --- a/src/conf_mode/arp.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 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 . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import node_changed -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['protocols', 'static', 'arp'] - arp = conf.get_config_dict(base, get_first_key=True) - - if 'interface' in arp: - for interface in arp['interface']: - tmp = node_changed(conf, base + ['interface', interface, 'address'], recursive=True) - if tmp: arp['interface'][interface].update({'address_old' : tmp}) - - return arp - -def verify(arp): - pass - -def generate(arp): - pass - -def apply(arp): - if not arp: - return None - - if 'interface' in arp: - for interface, interface_config in arp['interface'].items(): - # Delete old static ARP assignments first - if 'address_old' in interface_config: - for address in interface_config['address_old']: - call(f'ip neigh del {address} dev {interface}') - - # Add new static ARP entries to interface - if 'address' not in interface_config: - continue - for address, address_config in interface_config['address'].items(): - mac = address_config['mac'] - call(f'ip neigh replace {address} lladdr {mac} dev {interface}') - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/bcast_relay.py b/src/conf_mode/bcast_relay.py deleted file mode 100755 index 31c552f5a..000000000 --- a/src/conf_mode/bcast_relay.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2017-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 . - -import os - -from glob import glob -from netifaces import AF_INET -from sys import exit - -from vyos.config import Config -from vyos.configverify import verify_interface_exists -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.network import is_afi_configured -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file_base = r'/etc/default/udp-broadcast-relay' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'broadcast-relay'] - - relay = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - return relay - -def verify(relay): - if not relay or 'disabled' in relay: - return None - - for instance, config in relay.get('id', {}).items(): - # we don't have to check this instance when it's disabled - if 'disabled' in config: - continue - - # we certainly require a UDP port to listen to - if 'port' not in config: - raise ConfigError(f'Port number is mandatory for UDP broadcast relay "{instance}"') - - # Relaying data without two interface is kinda senseless ... - if len(config.get('interface', [])) < 2: - raise ConfigError('At least two interfaces are required for UDP broadcast relay "{instance}"') - - for interface in config.get('interface', []): - verify_interface_exists(interface) - if not is_afi_configured(interface, AF_INET): - raise ConfigError(f'Interface "{interface}" has no IPv4 address configured!') - - return None - -def generate(relay): - if not relay or 'disabled' in relay: - return None - - for config in glob(config_file_base + '*'): - os.remove(config) - - for instance, config in relay.get('id').items(): - # we don't have to check this instance when it's disabled - if 'disabled' in config: - continue - - config['instance'] = instance - render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.j2', - config) - - return None - -def apply(relay): - # first stop all running services - call('systemctl stop udp-broadcast-relay@*.service') - - if not relay or 'disable' in relay: - return None - - # start only required service instances - for instance, config in relay.get('id').items(): - # we don't have to check this instance when it's disabled - if 'disabled' in config: - continue - - call(f'systemctl start udp-broadcast-relay@{instance}.service') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/config_mgmt.py b/src/conf_mode/config_mgmt.py deleted file mode 100755 index c681a8405..000000000 --- a/src/conf_mode/config_mgmt.py +++ /dev/null @@ -1,96 +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 . - -import os -import sys - -from vyos import ConfigError -from vyos.config import Config -from vyos.config_mgmt import ConfigMgmt -from vyos.config_mgmt import commit_post_hook_dir, commit_hooks - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['system', 'config-management'] - if not conf.exists(base): - return None - - mgmt = ConfigMgmt(config=conf) - - return mgmt - -def verify(_mgmt): - return - -def generate(mgmt): - if mgmt is None: - return - - mgmt.initialize_revision() - -def apply(mgmt): - if mgmt is None: - return - - locations = mgmt.locations - archive_target = os.path.join(commit_post_hook_dir, - commit_hooks['commit_archive']) - if locations: - try: - os.symlink('/usr/bin/config-mgmt', archive_target) - except FileExistsError: - pass - except OSError as exc: - raise ConfigError from exc - else: - try: - os.unlink(archive_target) - except FileNotFoundError: - pass - except OSError as exc: - raise ConfigError from exc - - revisions = mgmt.max_revisions - revision_target = os.path.join(commit_post_hook_dir, - commit_hooks['commit_revision']) - if revisions > 0: - try: - os.symlink('/usr/bin/config-mgmt', revision_target) - except FileExistsError: - pass - except OSError as exc: - raise ConfigError from exc - else: - try: - os.unlink(revision_target) - except FileNotFoundError: - pass - except OSError as exc: - raise ConfigError from exc - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py deleted file mode 100755 index 7f6c71440..000000000 --- a/src/conf_mode/conntrack.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -import re - -from sys import exit - -from vyos.config import Config -from vyos.configdep import set_dependents, call_dependents -from vyos.utils.process import process_named_running -from vyos.utils.dict import dict_search -from vyos.utils.dict import dict_search_args -from vyos.utils.dict import dict_search_recursive -from vyos.utils.process import cmd -from vyos.utils.process import rc_cmd -from vyos.utils.process import run -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' -sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' -nftables_ct_file = r'/run/nftables-ct.conf' - -# Every ALG (Application Layer Gateway) consists of either a Kernel Object -# also called a Kernel Module/Driver or some rules present in iptables -module_map = { - 'ftp': { - 'ko': ['nf_nat_ftp', 'nf_conntrack_ftp'], - 'nftables': ['ct helper set "ftp_tcp" tcp dport {21} return'] - }, - 'h323': { - 'ko': ['nf_nat_h323', 'nf_conntrack_h323'], - 'nftables': ['ct helper set "ras_udp" udp dport {1719} return', - 'ct helper set "q931_tcp" tcp dport {1720} return'] - }, - 'nfs': { - 'nftables': ['ct helper set "rpc_tcp" tcp dport {111} return', - 'ct helper set "rpc_udp" udp dport {111} return'] - }, - 'pptp': { - 'ko': ['nf_nat_pptp', 'nf_conntrack_pptp'], - 'nftables': ['ct helper set "pptp_tcp" tcp dport {1723} return'], - 'ipv4': True - }, - 'sip': { - 'ko': ['nf_nat_sip', 'nf_conntrack_sip'], - 'nftables': ['ct helper set "sip_tcp" tcp dport {5060,5061} return', - 'ct helper set "sip_udp" udp dport {5060,5061} return'] - }, - 'sqlnet': { - 'nftables': ['ct helper set "tns_tcp" tcp dport {1521,1525,1536} return'] - }, - 'tftp': { - 'ko': ['nf_nat_tftp', 'nf_conntrack_tftp'], - 'nftables': ['ct helper set "tftp_udp" udp dport {69} return'] - }, -} - -valid_groups = [ - 'address_group', - 'domain_group', - 'network_group', - 'port_group' -] - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'conntrack'] - - conntrack = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - conntrack['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - conntrack['ipv4_nat_action'] = 'accept' if conf.exists(['nat']) else 'return' - conntrack['ipv6_nat_action'] = 'accept' if conf.exists(['nat66']) else 'return' - conntrack['wlb_action'] = 'accept' if conf.exists(['load-balancing', 'wan']) else 'return' - conntrack['wlb_local_action'] = conf.exists(['load-balancing', 'wan', 'enable-local-traffic']) - - conntrack['module_map'] = module_map - - if conf.exists(['service', 'conntrack-sync']): - set_dependents('conntrack_sync', conf) - - return conntrack - -def verify(conntrack): - for inet in ['ipv4', 'ipv6']: - if dict_search_args(conntrack, 'ignore', inet, 'rule') != None: - for rule, rule_config in conntrack['ignore'][inet]['rule'].items(): - if dict_search('destination.port', rule_config) or \ - dict_search('destination.group.port_group', rule_config) or \ - dict_search('source.port', rule_config) or \ - dict_search('source.group.port_group', rule_config): - if 'protocol' not in rule_config or rule_config['protocol'] not in ['tcp', 'udp']: - raise ConfigError(f'Port requires tcp or udp as protocol in rule {rule}') - - tcp_flags = dict_search_args(rule_config, 'tcp', 'flags') - if tcp_flags: - if dict_search_args(rule_config, 'protocol') != 'tcp': - raise ConfigError('Protocol must be tcp when specifying tcp flags') - - not_flags = dict_search_args(rule_config, 'tcp', 'flags', 'not') - if not_flags: - duplicates = [flag for flag in tcp_flags if flag in not_flags] - if duplicates: - raise ConfigError(f'Cannot match a tcp flag as set and not set') - - for side in ['destination', 'source']: - if side in rule_config: - side_conf = rule_config[side] - - if 'group' in side_conf: - if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: - raise ConfigError('Only one address-group, network-group or domain-group can be specified') - - for group in valid_groups: - if group in side_conf['group']: - group_name = side_conf['group'][group] - error_group = group.replace("_", "-") - - if group in ['address_group', 'network_group', 'domain_group']: - if 'address' in side_conf: - raise ConfigError(f'{error_group} and address cannot both be defined') - - if group_name and group_name[0] == '!': - group_name = group_name[1:] - - if inet == 'ipv6': - group = f'ipv6_{group}' - - group_obj = dict_search_args(conntrack['firewall'], 'group', group, group_name) - - if group_obj is None: - raise ConfigError(f'Invalid {error_group} "{group_name}" on ignore rule') - - if not group_obj: - Warning(f'{error_group} "{group_name}" has no members!') - - if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: - for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): - if 'protocol' not in rule_config: - raise ConfigError(f'Conntrack custom timeout rule {rule} requires protocol tcp or udp') - else: - if 'tcp' in rule_config['protocol'] and 'udp' in rule_config['protocol']: - raise ConfigError(f'conntrack custom timeout rule {rule} - Cant use both tcp and udp protocol') - return None - -def generate(conntrack): - if not os.path.exists(nftables_ct_file): - conntrack['first_install'] = True - - # Determine if conntrack is needed - conntrack['ipv4_firewall_action'] = 'return' - conntrack['ipv6_firewall_action'] = 'return' - - for rules, path in dict_search_recursive(conntrack['firewall'], 'rule'): - if any(('state' in rule_conf or 'connection_status' in rule_conf or 'offload_target' in rule_conf) for rule_conf in rules.values()): - if path[0] == 'ipv4': - conntrack['ipv4_firewall_action'] = 'accept' - elif path[0] == 'ipv6': - conntrack['ipv6_firewall_action'] = 'accept' - - render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) - render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) - render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) - return None - -def apply(conntrack): - # Depending on the enable/disable state of the ALG (Application Layer Gateway) - # modules we need to either insmod or rmmod the helpers. - - add_modules = [] - rm_modules = [] - - for module, module_config in module_map.items(): - if dict_search_args(conntrack, 'modules', module) is None: - if 'ko' in module_config: - unloaded = [mod for mod in module_config['ko'] if os.path.exists(f'/sys/module/{mod}')] - rm_modules.extend(unloaded) - else: - if 'ko' in module_config: - add_modules.extend(module_config['ko']) - - # Add modules before nftables uses them - if add_modules: - module_str = ' '.join(add_modules) - cmd(f'modprobe -a {module_str}') - - # Load new nftables ruleset - install_result, output = rc_cmd(f'nft -f {nftables_ct_file}') - if install_result == 1: - raise ConfigError(f'Failed to apply configuration: {output}') - - # Remove modules after nftables stops using them - if rm_modules: - module_str = ' '.join(rm_modules) - cmd(f'rmmod {module_str}') - - try: - call_dependents() - except ConfigError: - # Ignore config errors on dependent due to being called too early. Example: - # ConfigError("ConfigError('Interface ethN requires an IP address!')") - pass - - # We silently ignore all errors - # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080 - cmd(f'sysctl -f {sysctl_file}') - - 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/conntrack_sync.py b/src/conf_mode/conntrack_sync.py deleted file mode 100755 index 4fb2ce27f..000000000 --- a/src/conf_mode/conntrack_sync.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 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 . - -import os - -from sys import exit -from vyos.config import Config -from vyos.configverify import verify_interface_exists -from vyos.utils.dict import dict_search -from vyos.utils.process import process_named_running -from vyos.utils.file import read_file -from vyos.utils.process import call -from vyos.utils.process import run -from vyos.template import render -from vyos.template import get_ipv4 -from vyos.utils.network import is_addr_assigned -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = '/run/conntrackd/conntrackd.conf' - -def resync_vrrp(): - tmp = run('/usr/libexec/vyos/conf_mode/high-availability.py') - if tmp > 0: - print('ERROR: error restarting VRRP daemon!') - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'conntrack-sync'] - if not conf.exists(base): - return None - - conntrack = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, with_defaults=True) - - conntrack['hash_size'] = read_file('/sys/module/nf_conntrack/parameters/hashsize') - conntrack['table_size'] = read_file('/proc/sys/net/netfilter/nf_conntrack_max') - - conntrack['vrrp'] = conf.get_config_dict(['high-availability', 'vrrp', 'sync-group'], - get_first_key=True) - - return conntrack - -def verify(conntrack): - if not conntrack: - return None - - if 'interface' not in conntrack: - raise ConfigError('Interface not defined!') - - has_peer = False - for interface, interface_config in conntrack['interface'].items(): - verify_interface_exists(interface) - # Interface must not only exist, it must also carry an IP address - if len(get_ipv4(interface)) < 1: - raise ConfigError(f'Interface {interface} requires an IP address!') - if 'peer' in interface_config: - has_peer = True - - # If one interface runs in unicast mode instead of multicast, so must all the - # others, else conntrackd will error out with: "cannot use UDP with other - # dedicated link protocols" - if has_peer: - for interface, interface_config in conntrack['interface'].items(): - if 'peer' not in interface_config: - raise ConfigError('Can not mix unicast and multicast mode!') - - if 'expect_sync' in conntrack: - if len(conntrack['expect_sync']) > 1 and 'all' in conntrack['expect_sync']: - raise ConfigError('Can not configure expect-sync "all" with other protocols!') - - if 'listen_address' in conntrack: - for address in conntrack['listen_address']: - if not is_addr_assigned(address): - raise ConfigError(f'Specified listen-address {address} not assigned to any interface!') - - vrrp_group = dict_search('failover_mechanism.vrrp.sync_group', conntrack) - if vrrp_group == None: - raise ConfigError(f'No VRRP sync-group defined!') - if vrrp_group not in conntrack['vrrp']: - raise ConfigError(f'VRRP sync-group {vrrp_group} not configured!') - - return None - -def generate(conntrack): - if not conntrack: - if os.path.isfile(config_file): - os.unlink(config_file) - return None - - render(config_file, 'conntrackd/conntrackd.conf.j2', conntrack) - - return None - -def apply(conntrack): - systemd_service = 'conntrackd.service' - if not conntrack: - # Failover mechanism daemon should be indicated that it no longer needs - # to execute conntrackd actions on transition. This is only required - # once when conntrackd is stopped and taken out of service! - if process_named_running('conntrackd'): - resync_vrrp() - - call(f'systemctl stop {systemd_service}') - return None - - # Failover mechanism daemon should be indicated that it needs to execute - # conntrackd actions on transition. This is only required once when conntrackd - # is started the first time! - if not process_named_running('conntrackd'): - resync_vrrp() - - call(f'systemctl reload-or-restart {systemd_service}') - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/dhcp_relay.py b/src/conf_mode/dhcp_relay.py deleted file mode 100755 index 37d708847..000000000 --- a/src/conf_mode/dhcp_relay.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 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 . - -import os - -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.template import render -from vyos.base import Warning -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/dhcp-relay/dhcrelay.conf' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'dhcp-relay'] - if not conf.exists(base): - return None - - relay = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return relay - -def verify(relay): - # bail out early - looks like removal from running config - if not relay or 'disable' in relay: - return None - - if 'lo' in (dict_search('interface', relay) or []): - raise ConfigError('DHCP relay does not support the loopback interface.') - - if 'server' not in relay : - raise ConfigError('No DHCP relay server(s) configured.\n' \ - 'At least one DHCP relay server required.') - - if 'interface' in relay: - Warning('DHCP relay interface is DEPRECATED - please use upstream-interface and listen-interface instead!') - if 'upstream_interface' in relay or 'listen_interface' in relay: - raise ConfigError(' configuration is not compatible with upstream/listen interface') - else: - Warning(' is going to be deprecated.\n' \ - 'Please use and ') - - if 'upstream_interface' in relay and 'listen_interface' not in relay: - raise ConfigError('No listen-interface configured') - if 'listen_interface' in relay and 'upstream_interface' not in relay: - raise ConfigError('No upstream-interface configured') - - return None - -def generate(relay): - # bail out early - looks like removal from running config - if not relay or 'disable' in relay: - return None - - render(config_file, 'dhcp-relay/dhcrelay.conf.j2', relay) - return None - -def apply(relay): - # bail out early - looks like removal from running config - service_name = 'isc-dhcp-relay.service' - if not relay or 'disable' in relay: - call(f'systemctl stop {service_name}') - if os.path.exists(config_file): - os.unlink(config_file) - return None - - call(f'systemctl restart {service_name}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py deleted file mode 100755 index 7ebc560ba..000000000 --- a/src/conf_mode/dhcp_server.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from ipaddress import ip_address -from ipaddress import ip_network -from netaddr import IPRange -from sys import exit - -from vyos.config import Config -from vyos.pki import wrap_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.utils.dict import dict_search -from vyos.utils.dict import dict_search_args -from vyos.utils.file import chmod_775 -from vyos.utils.file import makedir -from vyos.utils.file import write_file -from vyos.utils.process import call -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' -ctrl_socket = '/run/kea/dhcp4-ctrl-socket' -config_file = '/run/kea/kea-dhcp4.conf' -lease_file = '/config/dhcp/dhcp4-leases.csv' -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? - - Lets assume we have a DHCP range from '192.0.2.1' to '192.0.2.100' - but want to exclude address '192.0.2.74' and '192.0.2.75'. We will - pass an input 'range_dict' in the format: - {'start' : '192.0.2.1', 'stop' : '192.0.2.100' } - and we will receive an output list of: - [{'start' : '192.0.2.1' , 'stop' : '192.0.2.73' }, - {'start' : '192.0.2.76', 'stop' : '192.0.2.100' }] - The resulting list can then be used in turn to build the proper dhcpd - configuration file. - """ - output = [] - # exclude list must be sorted for this to work - exclude_list = sorted(exclude_list) - range_start = range_dict['start'] - range_stop = range_dict['stop'] - range_last_exclude = '' - - for e in exclude_list: - 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)): - - # Build new address range ending one address before exclude address - r = { - 'start' : range_start, - 'stop' : str(ip_address(e) -1) - } - # On the next run our address range will start one address after - # the exclude address - range_start = str(ip_address(e) + 1) - - # on subsequent exclude addresses we can not - # append them to our output - if not (ip_address(r['start']) > ip_address(r['stop'])): - # Everything is fine, add range to result - output.append(r) - - # 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) - } - 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) - - return output - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'dhcp-server'] - 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) - - if 'shared_network_name' in dhcp: - for network, network_config in dhcp['shared_network_name'].items(): - if 'subnet' in network_config: - for subnet, subnet_config in network_config['subnet'].items(): - # If exclude IP addresses are defined we need to slice them out of - # the defined ranges - if {'exclude', 'range'} <= set(subnet_config): - 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 - - dhcp['shared_network_name'][network]['subnet'][subnet].update( - {'range' : new_range_dict}) - - if dict_search('failover.certificate', dhcp): - 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: - return None - - # 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.') - - # Inspect shared-network/subnet - listen_ok = False - subnets = [] - failover_ok = False - shared_networks = len(dhcp['shared_network_name']) - disabled_shared_networks = 0 - - - # A shared-network requires a subnet definition - for network, network_config in dhcp['shared_network_name'].items(): - if 'disable' in network_config: - 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.') - - for subnet, subnet_config in network_config['subnet'].items(): - # All delivered static routes require a next-hop to be set - 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!') - - # 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!') - - # 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}"!') - - # 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!') - - 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!') - if stop in network: - raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!') - - tmp = IPRange(range_config['start'], range_config['stop']) - networks.append(tmp) - - # Exclude addresses must be in bound - 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}"!') - - # 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}"!') - - 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) - 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}"!') - - # There must be one subnet connected to a listen interface. - # This only counts if the network itself is not disabled! - if 'disable' not in network_config: - if is_subnet_connected(subnet, primary=False): - listen_ok = True - - # Subnets must be non overlapping - if subnet in subnets: - 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.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!') - - if 'failover' in dhcp: - for key in ['name', 'remote', 'source_address', 'status']: - if key not in dhcp['failover']: - tmp = key.replace('_', '-') - raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') - - if len({'certificate', 'ca_certificate'} & set(dhcp['failover'])) == 1: - raise ConfigError(f'DHCP secured failover requires both certificate and CA certificate') - - if 'certificate' in dhcp['failover']: - cert_name = dhcp['failover']['certificate'] - - if cert_name not in dhcp['pki']['certificate']: - raise ConfigError(f'Invalid certificate specified for DHCP failover') - - if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'): - raise ConfigError(f'Invalid certificate specified for DHCP failover') - - if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'): - raise ConfigError(f'Missing private key on certificate specified for DHCP failover') - - if 'ca_certificate' in dhcp['failover']: - ca_cert_name = dhcp['failover']['ca_certificate'] - if ca_cert_name not in dhcp['pki']['ca']: - raise ConfigError(f'Invalid CA certificate specified for DHCP failover') - - if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): - raise ConfigError(f'Invalid CA certificate specified for DHCP failover') - - for address in (dict_search('listen_address', dhcp) or []): - if is_addr_assigned(address): - 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') - - - 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!') - - return None - -def generate(dhcp): - # bail out early - looks like removal from running config - if not dhcp or 'disable' in dhcp: - return None - - dhcp['lease_file'] = lease_file - dhcp['machine'] = os.uname().machine - - # Create directory for lease file if necessary - lease_dir = os.path.dirname(lease_file) - if not os.path.isdir(lease_dir): - makedir(lease_dir, group='vyattacfg') - chmod_775(lease_dir) - - # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way - if not os.path.exists(lease_file): - write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) - - for f in [cert_file, cert_key_file, ca_cert_file]: - if os.path.exists(f): - os.unlink(f) - - if 'failover' in dhcp: - if 'certificate' in dhcp['failover']: - cert_name = dhcp['failover']['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) - - dhcp['failover']['cert_file'] = cert_file - dhcp['failover']['cert_key_file'] = cert_key_file - - if 'ca_certificate' in dhcp['failover']: - ca_cert_name = dhcp['failover']['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) - - dhcp['failover']['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) - - return None - -def apply(dhcp): - services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] - - if not dhcp or 'disable' in dhcp: - for service in services: - call(f'systemctl stop {service}.service') - - if os.path.exists(config_file): - os.unlink(config_file) - - return None - - for service in services: - action = 'restart' - - if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: - action = 'stop' - - if service == 'kea-ctrl-agent' and 'failover' not in dhcp: - action = 'stop' - - call(f'systemctl {action} {service}.service') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py deleted file mode 100755 index 6537ca3c2..000000000 --- a/src/conf_mode/dhcpv6_relay.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 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 . - -import os - -from sys import exit - -from vyos.config import Config -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.template import is_ipv6 -from vyos.utils.process import call -from vyos.utils.network import is_ipv6_link_local -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = '/run/dhcp-relay/dhcrelay6.conf' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'dhcpv6-relay'] - if not conf.exists(base): - return None - - relay = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return relay - -def verify(relay): - # bail out early - looks like removal from running config - if not relay or 'disable' in relay: - return None - - if 'upstream_interface' not in relay: - raise ConfigError('At least one upstream interface required!') - for interface, config in relay['upstream_interface'].items(): - if 'address' not in config: - raise ConfigError('DHCPv6 server required for upstream ' \ - f'interface {interface}!') - - if 'listen_interface' not in relay: - raise ConfigError('At least one listen interface required!') - - # DHCPv6 relay requires at least one global unicat address assigned to the - # interface - for interface in relay['listen_interface']: - has_global = False - for addr in Interface(interface).get_addr(): - if is_ipv6(addr) and not is_ipv6_link_local(addr): - has_global = True - if not has_global: - raise ConfigError(f'Interface {interface} does not have global '\ - 'IPv6 address assigned!') - - return None - -def generate(relay): - # bail out early - looks like removal from running config - if not relay or 'disable' in relay: - return None - - render(config_file, 'dhcp-relay/dhcrelay6.conf.j2', relay) - return None - -def apply(relay): - # bail out early - looks like removal from running config - service_name = 'isc-dhcp-relay6.service' - if not relay or 'disable' in relay: - # DHCPv6 relay support is removed in the commit - call(f'systemctl stop {service_name}') - if os.path.exists(config_file): - os.unlink(config_file) - return None - - call(f'systemctl restart {service_name}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py deleted file mode 100755 index 9cc57dbcf..000000000 --- a/src/conf_mode/dhcpv6_server.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from ipaddress import ip_address -from ipaddress import ip_network -from sys import exit - -from vyos.config import Config -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.file import chmod_775 -from vyos.utils.file import makedir -from vyos.utils.file import write_file -from vyos.utils.dict import dict_search -from vyos.utils.network import is_subnet_connected -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = '/run/kea/kea-dhcp6.conf' -ctrl_socket = '/run/kea/dhcp6-ctrl-socket' -lease_file = '/config/dhcp/dhcp6-leases.csv' -user_group = '_kea' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'dhcpv6-server'] - if not conf.exists(base): - return None - - dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - return dhcpv6 - -def verify(dhcpv6): - # bail out early - looks like removal from running config - if not dhcpv6 or 'disable' in dhcpv6: - return None - - # If DHCP is enabled we need one share-network - if 'shared_network_name' not in dhcpv6: - raise ConfigError('No DHCPv6 shared networks configured. At least '\ - 'one DHCPv6 shared network must be configured.') - - # Inspect shared-network/subnet - subnets = [] - listen_ok = False - for network, network_config in dhcpv6['shared_network_name'].items(): - # A shared-network requires a subnet definition - if 'subnet' not in network_config: - raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\ - 'At least one lease subnet must be configured for '\ - 'each shared network!') - - for subnet, subnet_config in network_config['subnet'].items(): - if 'address_range' in subnet_config: - if 'start' in subnet_config['address_range']: - range6_start = [] - range6_stop = [] - for start, start_config in subnet_config['address_range']['start'].items(): - if 'stop' not in start_config: - raise ConfigError(f'address-range stop address for start "{start}" is not defined!') - stop = start_config['stop'] - - # Start address must be inside network - if not ip_address(start) in ip_network(subnet): - raise ConfigError(f'address-range start address "{start}" is not in subnet "{subnet}"!') - - # Stop address must be inside network - if not ip_address(stop) in ip_network(subnet): - raise ConfigError(f'address-range stop address "{stop}" is not in subnet "{subnet}"!') - - # Stop address must be greater or equal to start address - if not ip_address(stop) >= ip_address(start): - raise ConfigError(f'address-range stop address "{stop}" must be greater then or equal ' \ - f'to the range start address "{start}"!') - - # DHCPv6 range start address must be unique - two ranges can't - # start with the same address - makes no sense - if start in range6_start: - raise ConfigError(f'Conflicting DHCPv6 lease range: '\ - f'Pool start address "{start}" defined multipe times!') - range6_start.append(start) - - # DHCPv6 range stop address must be unique - two ranges can't - # end with the same address - makes no sense - if stop in range6_stop: - raise ConfigError(f'Conflicting DHCPv6 lease range: '\ - f'Pool stop address "{stop}" defined multipe times!') - range6_stop.append(stop) - - if 'prefix' in subnet_config: - for prefix in subnet_config['prefix']: - if ip_network(prefix) not in ip_network(subnet): - raise ConfigError(f'address-range prefix "{prefix}" is not in subnet "{subnet}""') - - # Prefix delegation sanity checks - if 'prefix_delegation' in subnet_config: - if 'prefix' not in subnet_config['prefix_delegation']: - raise ConfigError('prefix-delegation prefix not defined!') - - for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items(): - if 'delegated_length' not in prefix_config: - raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\ - f'must be configured') - - if 'prefix_length' not in prefix_config: - raise ConfigError('Length of delegated IPv6 prefix must be configured') - - if prefix_config['prefix_length'] > prefix_config['delegated_length']: - raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') - - # Static mappings don't require anything (but check if IP is in subnet if it's set) - if 'static_mapping' in subnet_config: - for mapping, mapping_config in subnet_config['static_mapping'].items(): - if 'ipv6_address' in mapping_config: - # Static address must be in subnet - if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): - raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') - - 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 'vendor_option' in subnet_config: - if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: - raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') - - # Subnets must be unique - if subnet in subnets: - raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') - subnets.append(subnet) - - # DHCPv6 requires at least one configured address range or one static mapping - # (FIXME: is not actually checked right now?) - - # There must be one subnet connected to a listen interface if network is not disabled. - if 'disable' not in network_config: - if is_subnet_connected(subnet): - listen_ok = True - - # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping - # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" - net = ip_network(subnet) - for n in subnets: - net2 = ip_network(n) - if (net != net2): - if net.overlaps(net2): - raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) - - if not listen_ok: - raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ - 'this machine. At least one subnet6 must be connected such that '\ - 'DHCPv6 listens on an interface!') - - - return None - -def generate(dhcpv6): - # bail out early - looks like removal from running config - if not dhcpv6 or 'disable' in dhcpv6: - return None - - dhcpv6['lease_file'] = lease_file - dhcpv6['machine'] = os.uname().machine - - # Create directory for lease file if necessary - lease_dir = os.path.dirname(lease_file) - if not os.path.isdir(lease_dir): - makedir(lease_dir, group='vyattacfg') - chmod_775(lease_dir) - - # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way - if not os.path.exists(lease_file): - write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) - - render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group) - return None - -def apply(dhcpv6): - # bail out early - looks like removal from running config - service_name = 'kea-dhcp6-server.service' - if not dhcpv6 or 'disable' in dhcpv6: - # DHCP server is removed in the commit - call(f'systemctl stop {service_name}') - if os.path.exists(config_file): - os.unlink(config_file) - return None - - call(f'systemctl restart {service_name}') - - 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/dns_dynamic.py b/src/conf_mode/dns_dynamic.py deleted file mode 100755 index 99fa8feee..000000000 --- a/src/conf_mode/dns_dynamic.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -import re -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.configverify import verify_interface_exists -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/ddclient/ddclient.conf' -systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' - -# Dynamic interfaces that might not exist when the configuration is loaded -dynamic_interfaces = ('pppoe', 'sstpc') - -# Protocols that require zone -zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', - 'nfsn', 'nsupdate'] -zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1'] - -# Protocols that do not require username -username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2', - 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla', - 'nsupdate', 'regfishde'] - -# Protocols that support TTL -ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn', - 'nsupdate'] - -# Protocols that support both IPv4 and IPv6 -dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns', - 'dyndns2', 'easydns', 'freedns', 'hetzner', 'infomaniak', - 'njalla'] - -# dyndns2 protocol in ddclient honors dual stack for selective servers -# because of the way it is implemented in ddclient -dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com'] - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'dns', 'dynamic'] - if not conf.exists(base): - return None - - dyndns = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - dyndns['config_file'] = config_file - return dyndns - -def verify(dyndns): - # bail out early - looks like removal from running config - if not dyndns or 'name' not in dyndns: - return None - - # Dynamic DNS service provider - configuration validation - for service, config in dyndns['name'].items(): - - error_msg_req = f'is required for Dynamic DNS service "{service}"' - error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' - - for field in ['protocol', 'address', 'host_name']: - if field not in config: - raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') - - # If dyndns address is an interface, ensure - # that the interface exists (or just warn if dynamic interface) - # and that web-options are not set - if config['address'] != 'web': - # exclude check interface for dynamic interfaces - if config['address'].startswith(dynamic_interfaces): - Warning(f'Interface "{config["address"]}" does not exist yet and cannot ' - f'be used for Dynamic DNS service "{service}" until it is up!') - else: - verify_interface_exists(config['address']) - if 'web_options' in config: - raise ConfigError(f'"web-options" is applicable only when using HTTP(S) ' - f'web request to obtain the IP address') - - # Warn if using checkip.dyndns.org, as it does not support HTTPS - # See: https://github.com/ddclient/ddclient/issues/597 - if 'web_options' in config: - if 'url' not in config['web_options']: - raise ConfigError(f'"url" in "web-options" {error_msg_req} ' - f'with protocol "{config["protocol"]}"') - elif re.search("^(https?://)?checkip\.dyndns\.org", config['web_options']['url']): - Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address ' - f'lookup. Please use a different IP address lookup service.') - - # RFC2136 uses 'key' instead of 'password' - if config['protocol'] != 'nsupdate' and 'password' not in config: - raise ConfigError(f'"password" {error_msg_req}') - - # Other RFC2136 specific configuration validation - if config['protocol'] == 'nsupdate': - if 'password' in config: - raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"') - for field in ['server', 'key']: - if field not in config: - raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"') - - if config['protocol'] in zone_necessary and 'zone' not in config: - raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') - - if config['protocol'] not in zone_supported and 'zone' in config: - raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"') - - if config['protocol'] not in username_unnecessary and 'username' not in config: - raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"') - - if config['protocol'] not in ttl_supported and 'ttl' in config: - raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"') - - if config['ip_version'] == 'both': - if config['protocol'] not in dualstack_supported: - raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' - f'with protocol "{config["protocol"]}"') - # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) - if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: - raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' - f'for "{config["server"]}" with protocol "{config["protocol"]}"') - - if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): - raise ConfigError(f'"expiry-time" must be greater than "wait-time" for ' - f'Dynamic DNS service "{service}"') - - return None - -def generate(dyndns): - # bail out early - looks like removal from running config - if not dyndns or 'name' not in dyndns: - return None - - render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) - render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) - return None - -def apply(dyndns): - systemd_service = 'ddclient.service' - # Reload systemd manager configuration - call('systemctl daemon-reload') - - # bail out early - looks like removal from running config - if not dyndns or 'name' not in dyndns: - call(f'systemctl stop {systemd_service}') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call(f'systemctl reload-or-restart {systemd_service}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/dns_forwarding.py b/src/conf_mode/dns_forwarding.py deleted file mode 100755 index c186f47af..000000000 --- a/src/conf_mode/dns_forwarding.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 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 . - -import os - -from netifaces import interfaces -from sys import exit -from glob import glob - -from vyos.config import Config -from vyos.hostsd_client import Client as hostsd_client -from vyos.template import render -from vyos.template import bracketize_ipv6 -from vyos.utils.process import call -from vyos.utils.permission import chown -from vyos.utils.dict import dict_search - -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -pdns_rec_user = pdns_rec_group = 'pdns' -pdns_rec_run_dir = '/run/powerdns' -pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' -pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' -pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' -pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' - -hostsd_tag = 'static' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'dns', 'forwarding'] - if not conf.exists(base): - return None - - dns = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - # some additions to the default dictionary - if 'system' in dns: - base_nameservers = ['system', 'name-server'] - if conf.exists(base_nameservers): - dns.update({'system_name_server': conf.return_values(base_nameservers)}) - - if 'authoritative_domain' in dns: - dns['authoritative_zones'] = [] - dns['authoritative_zone_errors'] = [] - for node in dns['authoritative_domain']: - zonedata = dns['authoritative_domain'][node] - if ('disable' in zonedata) or (not 'records' in zonedata): - continue - zone = { - 'name': node, - 'file': "{}/zone.{}.conf".format(pdns_rec_run_dir, node), - 'records': [], - } - - recorddata = zonedata['records'] - - for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ns', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: - if rtype not in recorddata: - continue - for subnode in recorddata[rtype]: - if 'disable' in recorddata[rtype][subnode]: - continue - - rdata = recorddata[rtype][subnode] - - if rtype in [ 'a', 'aaaa' ]: - if not 'address' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one address is required') - continue - - if subnode == 'any': - subnode = '*' - - for address in rdata['address']: - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': address - }) - elif rtype in ['cname', 'ptr', 'ns']: - if not 'target' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: target is required') - continue - - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': '{}.'.format(rdata['target']) - }) - elif rtype == 'mx': - if not 'server' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one server is required') - continue - - for servername in rdata['server']: - serverdata = rdata['server'][servername] - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': '{} {}.'.format(serverdata['priority'], servername) - }) - elif rtype == 'txt': - if not 'value' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one value is required') - continue - - for value in rdata['value']: - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': "\"{}\"".format(value.replace("\"", "\\\"")) - }) - elif rtype == 'spf': - if not 'value' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: value is required') - continue - - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': '"{}"'.format(rdata['value'].replace("\"", "\\\"")) - }) - elif rtype == 'srv': - if not 'entry' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one entry is required') - continue - - for entryno in rdata['entry']: - entrydata = rdata['entry'][entryno] - if not 'hostname' in entrydata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: hostname is required for entry {entryno}') - continue - - if not 'port' in entrydata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: port is required for entry {entryno}') - continue - - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': '{} {} {} {}.'.format(entrydata['priority'], entrydata['weight'], entrydata['port'], entrydata['hostname']) - }) - elif rtype == 'naptr': - if not 'rule' in rdata: - dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one rule is required') - continue - - for ruleno in rdata['rule']: - ruledata = rdata['rule'][ruleno] - flags = "" - if 'lookup-srv' in ruledata: - flags += "S" - if 'lookup-a' in ruledata: - flags += "A" - if 'resolve-uri' in ruledata: - flags += "U" - if 'protocol-specific' in ruledata: - flags += "P" - - if 'order' in ruledata: - order = ruledata['order'] - else: - order = ruleno - - if 'regexp' in ruledata: - regexp= ruledata['regexp'].replace("\"", "\\\"") - else: - regexp = '' - - if ruledata['replacement']: - replacement = '{}.'.format(ruledata['replacement']) - else: - replacement = '' - - zone['records'].append({ - 'name': subnode, - 'type': rtype.upper(), - 'ttl': rdata['ttl'], - 'value': '{} {} "{}" "{}" "{}" {}'.format(order, ruledata['preference'], flags, ruledata['service'], regexp, replacement) - }) - - dns['authoritative_zones'].append(zone) - - return dns - -def verify(dns): - # bail out early - looks like removal from running config - if not dns: - return None - - if 'listen_address' not in dns: - raise ConfigError('DNS forwarding requires a listen-address') - - if 'allow_from' not in dns: - raise ConfigError('DNS forwarding requires an allow-from network') - - # we can not use dict_search() when testing for domain servers - # as a domain will contains dot's which is out dictionary delimiter. - if 'domain' in dns: - for domain in dns['domain']: - if 'name_server' not in dns['domain'][domain]: - raise ConfigError(f'No server configured for domain {domain}!') - - if 'dns64_prefix' in dns: - dns_prefix = dns['dns64_prefix'].split('/')[1] - # RFC 6147 requires prefix /96 - if int(dns_prefix) != 96: - raise ConfigError('DNS 6to4 prefix must be of length /96') - - if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']: - for error in dns['authoritative_zone_errors']: - print(error) - raise ConfigError('Invalid authoritative records have been defined') - - if 'system' in dns: - if not 'system_name_server' in dns: - print('Warning: No "system name-server" configured') - - return None - -def generate(dns): - # bail out early - looks like removal from running config - if not dns: - return None - - render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) - - render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', - dns, user=pdns_rec_user, group=pdns_rec_group) - - for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): - os.unlink(zone_filename) - - if 'authoritative_zones' in dns: - for zone in dns['authoritative_zones']: - render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', - zone, user=pdns_rec_user, group=pdns_rec_group) - - - # if vyos-hostsd didn't create its files yet, create them (empty) - for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: - with open(file, 'a'): - pass - chown(file, user=pdns_rec_user, group=pdns_rec_group) - - return None - -def apply(dns): - if not dns: - # DNS forwarding is removed in the commit - call('systemctl stop pdns-recursor.service') - - if os.path.isfile(pdns_rec_config_file): - os.unlink(pdns_rec_config_file) - - for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): - os.unlink(zone_filename) - else: - ### first apply vyos-hostsd config - hc = hostsd_client() - - # add static nameservers to hostsd so they can be joined with other - # sources - hc.delete_name_servers([hostsd_tag]) - if 'name_server' in dns: - # 'name_server' is of the form - # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} - # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] - nslist = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) - for (h, p) in dns['name_server'].items()] - hc.add_name_servers({hostsd_tag: nslist}) - - # delete all nameserver tags - hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor()) - - ## add nameserver tags - the order determines the nameserver order! - # our own tag (static) - hc.add_name_server_tags_recursor([hostsd_tag]) - - if 'system' in dns: - hc.add_name_server_tags_recursor(['system']) - else: - hc.delete_name_server_tags_recursor(['system']) - - # add dhcp nameserver tags for configured interfaces - if 'system_name_server' in dns: - for interface in dns['system_name_server']: - # system_name_server key contains both IP addresses and interface - # names (DHCP) to use DNS servers. We need to check if the - # value is an interface name - only if this is the case, add the - # interface based DNS forwarder. - if interface in interfaces(): - 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())) - if 'domain' in dns: - zones = dns['domain'] - for domain in zones.keys(): - # 'name_server' is of the form - # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} - # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] - zones[domain]['name_server'] = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) - for (h, p) in zones[domain]['name_server'].items()] - hc.add_forward_zones(zones) - - # hostsd generates NTAs for the authoritative zones - # the list and keys() are required as get returns a dict, not list - hc.delete_authoritative_zones(list(hc.get_authoritative_zones())) - if 'authoritative_zones' in dns: - hc.add_authoritative_zones(list(map(lambda zone: zone['name'], dns['authoritative_zones']))) - - # call hostsd to generate forward-zones and its lua-config-file - hc.apply() - - ### finally (re)start pdns-recursor - call('systemctl restart pdns-recursor.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/firewall.py b/src/conf_mode/firewall.py index da6724fde..acb7dfa41 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -42,9 +42,6 @@ from vyos import airbag airbag.enable() -nat_conf_script = 'nat.py' -policy_route_conf_script = 'policy-route.py' - nftables_conf = '/run/nftables.conf' sysfs_config = { diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py deleted file mode 100755 index 206f513c8..000000000 --- a/src/conf_mode/flow_accounting_conf.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -import re - -from sys import exit -from ipaddress import ip_address - -from vyos.base import Warning -from vyos.config import Config -from vyos.config import config_dict_merge -from vyos.configverify import verify_vrf -from vyos.ifconfig import Section -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import cmd -from vyos.utils.process import run -from vyos.utils.network import is_addr_assigned -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -uacctd_conf_path = '/run/pmacct/uacctd.conf' -systemd_service = 'uacctd.service' -systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' -nftables_nflog_table = 'raw' -nftables_nflog_chain = 'VYOS_PREROUTING_HOOK' -egress_nftables_nflog_table = 'inet mangle' -egress_nftables_nflog_chain = 'FORWARD' - -# get nftables rule dict for chain in table -def _nftables_get_nflog(chain, table): - # define list with rules - rules = [] - - # prepare regex for parsing rules - rule_pattern = '[io]ifname "(?P[\w\.\*\-]+)".*handle (?P[\d]+)' - rule_re = re.compile(rule_pattern) - - # run nftables, save output and split it by lines - nftables_command = f'nft -a list chain {table} {chain}' - tmp = cmd(nftables_command, message='Failed to get flows list') - # parse each line and add information to list - for current_rule in tmp.splitlines(): - if 'FLOW_ACCOUNTING_RULE' not in current_rule: - continue - current_rule_parsed = rule_re.search(current_rule) - if current_rule_parsed: - groups = current_rule_parsed.groupdict() - rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] }) - - # return list with rules - return rules - -def _nftables_config(configured_ifaces, direction, length=None): - # define list of nftables commands to modify settings - nftable_commands = [] - nftables_chain = nftables_nflog_chain - nftables_table = nftables_nflog_table - - if direction == "egress": - nftables_chain = egress_nftables_nflog_chain - nftables_table = egress_nftables_nflog_table - - # prepare extended list with configured interfaces - configured_ifaces_extended = [] - for iface in configured_ifaces: - configured_ifaces_extended.append({ 'iface': iface }) - - # get currently configured interfaces with nftables rules - active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table) - - # compare current active list with configured one and delete excessive interfaces, add missed - active_nflog_ifaces = [] - for rule in active_nflog_rules: - interface = rule['interface'] - if interface not in configured_ifaces: - table = rule['table'] - handle = rule['handle'] - nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}') - else: - active_nflog_ifaces.append({ - 'iface': interface, - }) - - # do not create new rules for already configured interfaces - for iface in active_nflog_ifaces: - if iface in active_nflog_ifaces and iface in configured_ifaces_extended: - configured_ifaces_extended.remove(iface) - - # create missed rules - for iface_extended in configured_ifaces_extended: - iface = iface_extended['iface'] - iface_prefix = "o" if direction == "egress" else "i" - rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' - nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') - # Also add IPv6 ingres logging - if nftables_table == nftables_nflog_table: - nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') - - # change nftables - for command in nftable_commands: - cmd(command, raising=ConfigError) - - -def _nftables_trigger_setup(operation: str) -> None: - """Add a dummy rule to unlock the main pmacct loop with a packet-trigger - - Args: - operation (str): 'add' or 'delete' a trigger - """ - # check if a chain exists - table_exists = False - if run('nft -snj list table ip pmacct') == 0: - table_exists = True - - if operation == 'delete' and table_exists: - nft_cmd: str = 'nft delete table ip pmacct' - cmd(nft_cmd, raising=ConfigError) - if operation == 'add' and not table_exists: - nft_cmds: list[str] = [ - 'nft add table ip pmacct', - 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }', - 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER' - ] - for nft_cmd in nft_cmds: - cmd(nft_cmd, raising=ConfigError) - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'flow-accounting'] - if not conf.exists(base): - return None - - flow_accounting = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - # We have gathered the dict representation of the CLI, but there are - # default values which we need to conditionally update into the - # dictionary retrieved. - default_values = conf.get_config_defaults(**flow_accounting.kwargs, - recursive=True) - - # 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_accounting = config_dict_merge(default_values, flow_accounting) - - return flow_accounting - -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, ' \ - 'or not set "disable-imt" for flow-accounting!') - - # Check if at least one interface is configured - if 'interface' not in flow_config: - raise ConfigError('Flow accounting requires at least one interface to ' \ - 'be configured!') - - # check that all configured interfaces exists in the system - for interface in flow_config['interface']: - if interface not in Section.interfaces(): - # Changed from error to warning to allow adding dynamic interfaces - # and interface templates - Warning(f'Interface "{interface}" is not presented in the system') - - # 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!') - - # check NetFlow configuration - if 'netflow' in flow_config: - # check if vrf is defined for netflow - netflow_vrf = None - if 'vrf' in flow_config: - netflow_vrf = flow_config['vrf'] - - # check if at least one NetFlow collector is configured if NetFlow configuration is presented - if 'server' not in flow_config['netflow']: - raise ConfigError('You need to configure at least one NetFlow server!') - - # Check if configured netflow source-address exist in the system - if 'source_address' in flow_config['netflow']: - if not is_addr_assigned(flow_config['netflow']['source_address'], netflow_vrf): - tmp = flow_config['netflow']['source_address'] - raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') - - # Check if engine-id compatible with selected protocol version - if 'engine_id' in flow_config['netflow']: - v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' - v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' - engine_id = flow_config['netflow']['engine_id'] - version = flow_config['netflow']['version'] - - if flow_config['netflow']['version'] == '5': - regex_filter = re.compile(v5_filter) - if not regex_filter.search(engine_id): - raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\ - f'together with NetFlow protocol version "{version}"!') - else: - regex_filter = re.compile(v9v10_filter) - if not regex_filter.search(flow_config['netflow']['engine_id']): - raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\ - f'with NetFlow protocol version "{version}"!') - - # return True if all checks were passed - return True - -def generate(flow_config): - if not flow_config: - return None - - render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config) - render(systemd_override, 'pmacct/override.conf.j2', flow_config) - # Reload systemd manager configuration - call('systemctl daemon-reload') - -def apply(flow_config): - # Check if flow-accounting was removed and define command - if not flow_config: - _nftables_config([], 'ingress') - _nftables_config([], 'egress') - - # Stop flow-accounting daemon and remove configuration file - call(f'systemctl stop {systemd_service}') - if os.path.exists(uacctd_conf_path): - os.unlink(uacctd_conf_path) - - # must be done after systemctl - _nftables_trigger_setup('delete') - - return - - # Start/reload flow-accounting daemon - call(f'systemctl restart {systemd_service}') - - # configure nftables rules for defined interfaces - if 'interface' in flow_config: - _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length']) - - # configure egress the same way if configured otherwise remove it - if 'enable_egress' in flow_config: - _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length']) - else: - _nftables_config([], 'egress') - - # add a trigger for signal processing - _nftables_trigger_setup('add') - - -if __name__ == '__main__': - try: - config = get_config() - verify(config) - generate(config) - apply(config) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/host_name.py b/src/conf_mode/host_name.py deleted file mode 100755 index 6204cf247..000000000 --- a/src/conf_mode/host_name.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -import sys -import copy - -import vyos.hostsd_client - -from vyos.base import Warning -from vyos.config import Config -from vyos.ifconfig import Section -from vyos.template import is_ip -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import process_named_running -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -default_config_data = { - 'hostname': 'vyos', - 'domain_name': '', - 'domain_search': [], - 'nameserver': [], - 'nameservers_dhcp_interfaces': {}, - 'static_host_mapping': {} -} - -hostsd_tag = 'system' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - hosts = copy.deepcopy(default_config_data) - - hosts['hostname'] = conf.return_value(['system', 'host-name']) - - # This may happen if the config is not loaded yet, - # e.g. if run by cloud-init - if not hosts['hostname']: - hosts['hostname'] = default_config_data['hostname'] - - if conf.exists(['system', 'domain-name']): - hosts['domain_name'] = conf.return_value(['system', 'domain-name']) - hosts['domain_search'].append(hosts['domain_name']) - - if conf.exists(['system', 'domain-search']): - for search in conf.return_values(['system', 'domain-search']): - hosts['domain_search'].append(search) - - if conf.exists(['system', 'name-server']): - for ns in conf.return_values(['system', 'name-server']): - if is_ip(ns): - hosts['nameserver'].append(ns) - else: - tmp = '' - if_type = Section.section(ns) - if conf.exists(['interfaces', if_type, ns, 'address']): - tmp = conf.return_values(['interfaces', if_type, ns, 'address']) - - hosts['nameservers_dhcp_interfaces'].update({ ns : tmp }) - - # system static-host-mapping - for hn in conf.list_nodes(['system', 'static-host-mapping', 'host-name']): - hosts['static_host_mapping'][hn] = {} - hosts['static_host_mapping'][hn]['address'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'inet']) - hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'alias']) - - return hosts - - -def verify(hosts): - if hosts is None: - return None - - # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)" - hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$") - if not hostname_regex.match(hosts['hostname']): - raise ConfigError('Invalid host name ' + hosts["hostname"]) - - # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length" - length = len(hosts['hostname']) - if length < 1 or length > 63: - raise ConfigError( - 'Invalid host-name length, must be less than 63 characters') - - all_static_host_mapping_addresses = [] - # static mappings alias hostname - for host, hostprops in hosts['static_host_mapping'].items(): - if not hostprops['address']: - raise ConfigError(f'IP address required for static-host-mapping "{host}"') - all_static_host_mapping_addresses.append(hostprops['address']) - for a in hostprops['aliases']: - if not hostname_regex.match(a) and len(a) != 0: - raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') - - for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items(): - # Warnin user if interface does not have DHCP or DHCPv6 configured - if not set(interface_config).intersection(['dhcp', 'dhcpv6']): - Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!') - - return None - - -def generate(config): - pass - -def apply(config): - if config is None: - return None - - ## Send the updated data to vyos-hostsd - try: - hc = vyos.hostsd_client.Client() - - hc.set_host_name(config['hostname'], config['domain_name']) - - hc.delete_search_domains([hostsd_tag]) - if config['domain_search']: - hc.add_search_domains({hostsd_tag: config['domain_search']}) - - hc.delete_name_servers([hostsd_tag]) - if config['nameserver']: - hc.add_name_servers({hostsd_tag: config['nameserver']}) - - # add our own tag's (system) nameservers and search to resolv.conf - hc.delete_name_server_tags_system(hc.get_name_server_tags_system()) - hc.add_name_server_tags_system([hostsd_tag]) - - # this will add the dhcp client nameservers to resolv.conf - for intf in config['nameservers_dhcp_interfaces']: - hc.add_name_server_tags_system([f'dhcp-{intf}', f'dhcpv6-{intf}']) - - hc.delete_hosts([hostsd_tag]) - if config['static_host_mapping']: - hc.add_hosts({hostsd_tag: config['static_host_mapping']}) - - hc.apply() - except vyos.hostsd_client.VyOSHostsdError as e: - raise ConfigError(str(e)) - - ## Actually update the hostname -- vyos-hostsd doesn't do that - - # No domain name -- the Debian way. - hostname_new = config['hostname'] - - # rsyslog runs into a race condition at boot time with systemd - # restart rsyslog only if the hostname changed. - hostname_old = cmd('hostnamectl --static') - call(f'hostnamectl set-hostname --static {hostname_new}') - - # Restart services that use the hostname - if hostname_new != hostname_old: - call("systemctl restart rsyslog.service") - - # If SNMP is running, restart it too - if process_named_running('snmpd'): - call('systemctl restart snmpd.service') - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py deleted file mode 100755 index 3dc5dfc01..000000000 --- a/src/conf_mode/https.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os -import sys -import json - -from copy import deepcopy -from time import sleep - -import vyos.defaults -import vyos.certbot_util - -from vyos.base import Warning -from vyos.config import Config -from vyos.configdiff import get_config_diff -from vyos.configverify import verify_vrf -from vyos import ConfigError -from vyos.pki import wrap_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running -from vyos.utils.process import is_systemd_service_active -from vyos.utils.network import check_port_availability -from vyos.utils.network import is_listen_port_bind_service -from vyos.utils.file import write_file - -from vyos import airbag -airbag.enable() - -config_file = '/etc/nginx/sites-available/default' -systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' -cert_dir = '/etc/ssl/certs' -key_dir = '/etc/ssl/private' -certbot_dir = vyos.defaults.directories['certbot'] - -api_config_state = '/run/http-api-state' -systemd_service = '/run/systemd/system/vyos-http-api.service' - -# https config needs to coordinate several subsystems: api, certbot, -# self-signed certificate, as well as the virtual hosts defined within the -# https config definition itself. Consequently, one needs a general dict, -# encompassing the https and other configs, and a list of such virtual hosts -# (server blocks in nginx terminology) to pass to the jinja2 template. -default_server_block = { - 'id' : '', - 'address' : '*', - 'port' : '443', - 'name' : ['_'], - 'api' : False, - 'vyos_cert' : {}, - 'certbot' : False -} - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'https'] - if not conf.exists(base): - return None - - diff = get_config_diff(conf) - - https = conf.get_config_dict(base, get_first_key=True) - - if https: - https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True) - - https['children_changed'] = diff.node_changed_children(base) - https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) - - if 'api' not in https: - return https - - http_api = conf.get_config_dict(base + ['api'], key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - if http_api.from_defaults(['graphql']): - del http_api['graphql'] - - # Do we run inside a VRF context? - vrf_path = ['service', 'https', 'vrf'] - if conf.exists(vrf_path): - http_api['vrf'] = conf.return_value(vrf_path) - - https['api'] = http_api - - return https - -def verify(https): - from vyos.utils.dict import dict_search - - if https is None: - return None - - if 'certificates' in https: - certificates = https['certificates'] - - if 'certificate' in certificates: - if not https['pki']: - raise ConfigError("PKI is not configured") - - cert_name = certificates['certificate'] - - if cert_name not in https['pki']['certificate']: - raise ConfigError("Invalid certificate on https configuration") - - pki_cert = https['pki']['certificate'][cert_name] - - if 'certificate' not in pki_cert: - raise ConfigError("Missing certificate on https configuration") - - if 'private' not in pki_cert or 'key' not in pki_cert['private']: - raise ConfigError("Missing certificate private key on https configuration") - - if 'certbot' in https['certificates']: - vhost_names = [] - for _, vh_conf in https.get('virtual-host', {}).items(): - vhost_names += vh_conf.get('server-name', []) - domains = https['certificates']['certbot'].get('domain-name', []) - domains_found = [domain for domain in domains if domain in vhost_names] - if not domains_found: - raise ConfigError("At least one 'virtual-host server-name' " - "matching the 'certbot domain-name' is required.") - - server_block_list = [] - - # organize by vhosts - vhost_dict = https.get('virtual-host', {}) - - if not vhost_dict: - # no specified virtual hosts (server blocks); use default - server_block_list.append(default_server_block) - else: - for vhost in list(vhost_dict): - server_block = deepcopy(default_server_block) - data = vhost_dict.get(vhost, {}) - server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('port', '443') - server_block_list.append(server_block) - - for entry in server_block_list: - _address = entry.get('address') - _address = '0.0.0.0' if _address == '*' else _address - _port = entry.get('port') - proto = 'tcp' - if check_port_availability(_address, int(_port), proto) is not True and \ - not is_listen_port_bind_service(int(_port), 'nginx'): - raise ConfigError(f'"{proto}" port "{_port}" is used by another service') - - verify_vrf(https) - - # Verify API server settings, if present - if 'api' in https: - keys = dict_search('api.keys.id', https) - gql_auth_type = dict_search('api.graphql.authentication.type', https) - - # If "api graphql" is not defined and `gql_auth_type` is None, - # there's certainly no JWT auth option, and keys are required - jwt_auth = (gql_auth_type == "token") - - # Check for incomplete key configurations in every case - valid_keys_exist = False - if keys: - for k in keys: - if 'key' not in keys[k]: - raise ConfigError(f'Missing HTTPS API key string for key id "{k}"') - else: - valid_keys_exist = True - - # If only key-based methods are enabled, - # fail the commit if no valid key configurations are found - if (not valid_keys_exist) and (not jwt_auth): - raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled') - - if (not valid_keys_exist) and jwt_auth: - Warning(f'API keys are not configured: the classic (non-GraphQL) API will be unavailable.') - - return None - -def generate(https): - if https is None: - return None - - if 'api' not in https: - if os.path.exists(systemd_service): - os.unlink(systemd_service) - else: - render(systemd_service, 'https/vyos-http-api.service.j2', https['api']) - with open(api_config_state, 'w') as f: - json.dump(https['api'], f, indent=2) - - server_block_list = [] - - # organize by vhosts - - vhost_dict = https.get('virtual-host', {}) - - if not vhost_dict: - # no specified virtual hosts (server blocks); use default - server_block_list.append(default_server_block) - else: - for vhost in list(vhost_dict): - server_block = deepcopy(default_server_block) - server_block['id'] = vhost - data = vhost_dict.get(vhost, {}) - server_block['address'] = data.get('listen-address', '*') - server_block['port'] = data.get('port', '443') - name = data.get('server-name', ['_']) - server_block['name'] = name - allow_client = data.get('allow-client', {}) - server_block['allow_client'] = allow_client.get('address', []) - server_block_list.append(server_block) - - # get certificate data - - cert_dict = https.get('certificates', {}) - - if 'certificate' in cert_dict: - cert_name = cert_dict['certificate'] - pki_cert = https['pki']['certificate'][cert_name] - - cert_path = os.path.join(cert_dir, f'{cert_name}.pem') - key_path = os.path.join(key_dir, f'{cert_name}.pem') - - server_cert = str(wrap_certificate(pki_cert['certificate'])) - if 'ca-certificate' in cert_dict: - ca_cert = cert_dict['ca-certificate'] - server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) - - write_file(cert_path, server_cert) - write_file(key_path, wrap_private_key(pki_cert['private']['key'])) - - vyos_cert_data = { - 'crt': cert_path, - 'key': key_path - } - - for block in server_block_list: - block['vyos_cert'] = vyos_cert_data - - # letsencrypt certificate using certbot - - certbot = False - cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) - if cert_domains: - certbot = True - for domain in cert_domains: - sub_list = vyos.certbot_util.choose_server_block(server_block_list, - domain) - if sub_list: - for sb in sub_list: - sb['certbot'] = True - sb['certbot_dir'] = certbot_dir - # certbot organizes certificates by first domain - sb['certbot_domain_dir'] = cert_domains[0] - - if 'api' in list(https): - vhost_list = https.get('api-restrict', {}).get('virtual-host', []) - if not vhost_list: - for block in server_block_list: - block['api'] = True - else: - for block in server_block_list: - if block['id'] in vhost_list: - block['api'] = True - - data = { - 'server_block_list': server_block_list, - 'certbot': certbot - } - - render(config_file, 'https/nginx.default.j2', data) - render(systemd_override, 'https/override.conf.j2', https) - return None - -def apply(https): - # Reload systemd manager configuration - call('systemctl daemon-reload') - http_api_service_name = 'vyos-http-api.service' - https_service_name = 'nginx.service' - - if https is None: - if is_systemd_service_active(f'{http_api_service_name}'): - call(f'systemctl stop {http_api_service_name}') - call(f'systemctl stop {https_service_name}') - return - - if 'api' in https['children_changed']: - if 'api' in https: - if is_systemd_service_running(f'{http_api_service_name}'): - call(f'systemctl reload {http_api_service_name}') - else: - call(f'systemctl restart {http_api_service_name}') - # Let uvicorn settle before (possibly) restarting nginx - sleep(1) - else: - if is_systemd_service_active(f'{http_api_service_name}'): - call(f'systemctl stop {http_api_service_name}') - - if (not is_systemd_service_running(f'{https_service_name}') or - https['api_add_or_delete'] or - set(https['children_changed']) - set(['api'])): - call(f'systemctl restart {https_service_name}') - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/igmp_proxy.py b/src/conf_mode/igmp_proxy.py deleted file mode 100755 index 40db417dd..000000000 --- a/src/conf_mode/igmp_proxy.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 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 . - -import os - -from sys import exit -from netifaces import interfaces - -from vyos.base import Warning -from vyos.config import Config -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/etc/igmpproxy.conf' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['protocols', 'igmp-proxy'] - igmp_proxy = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_defaults=True) - - if conf.exists(['protocols', 'igmp']): - igmp_proxy.update({'igmp_configured': ''}) - - if conf.exists(['protocols', 'pim']): - igmp_proxy.update({'pim_configured': ''}) - - return igmp_proxy - -def verify(igmp_proxy): - # bail out early - looks like removal from running config - if not igmp_proxy or 'disable' in igmp_proxy: - return None - - if 'igmp_configured' in igmp_proxy or 'pim_configured' in igmp_proxy: - raise ConfigError('Can not configure both IGMP proxy and PIM '\ - 'at the same time') - - # at least two interfaces are required, one upstream and one downstream - if 'interface' not in igmp_proxy or len(igmp_proxy['interface']) < 2: - raise ConfigError('Must define exactly one upstream and at least one ' \ - 'downstream interface!') - - upstream = 0 - for interface, config in igmp_proxy['interface'].items(): - if interface not in interfaces(): - raise ConfigError(f'Interface "{interface}" does not exist') - if dict_search('role', config) == 'upstream': - upstream += 1 - - if upstream == 0: - raise ConfigError('At least 1 upstream interface is required!') - elif upstream > 1: - raise ConfigError('Only 1 upstream interface allowed!') - - return None - -def generate(igmp_proxy): - # bail out early - looks like removal from running config - if not igmp_proxy: - return None - - # bail out early - service is disabled, but inform user - if 'disable' in igmp_proxy: - Warning('IGMP Proxy will be deactivated because it is disabled') - return None - - render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy) - - return None - -def apply(igmp_proxy): - if not igmp_proxy or 'disable' in igmp_proxy: - # IGMP Proxy support is removed in the commit - call('systemctl stop igmpproxy.service') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call('systemctl restart igmpproxy.service') - - 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/intel_qat.py b/src/conf_mode/intel_qat.py deleted file mode 100755 index e4b248675..000000000 --- a/src/conf_mode/intel_qat.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os -import re - -from sys import exit - -from vyos.config import Config -from vyos.utils.process import popen -from vyos.utils.process import run -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -qat_init_script = '/etc/init.d/qat_service' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - data = {} - - if conf.exists(['system', 'acceleration', 'qat']): - data.update({'qat_enable' : ''}) - - if conf.exists(['vpn', 'ipsec']): - data.update({'ipsec' : ''}) - - if conf.exists(['interfaces', 'openvpn']): - data.update({'openvpn' : ''}) - - return data - - -def vpn_control(action, force_ipsec=False): - # XXX: Should these commands report failure? - if action == 'restore' and force_ipsec: - return run('ipsec start') - - return run(f'ipsec {action}') - - -def verify(qat): - if 'qat_enable' not in qat: - return - - # Check if QAT service installed - if not os.path.exists(qat_init_script): - raise ConfigError('QAT init script not found') - - # Check if QAT device exist - output, err = popen('lspci -nn', decode='utf-8') - if not err: - # PCI id | Chipset - # 19e2 -> C3xx - # 37c8 -> C62x - # 0435 -> DH895 - # 6f54 -> D15xx - # 18ee -> QAT_200XX - data = re.findall( - '(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)|(8086:18ee)', output) - # If QAT devices found - if not data: - raise ConfigError('No QAT acceleration device found') - -def apply(qat): - # Shutdown VPN service which can use QAT - if 'ipsec' in qat: - vpn_control('stop') - - # Enable/Disable QAT service - if 'qat_enable' in qat: - run(f'{qat_init_script} start') - else: - run(f'{qat_init_script} stop') - - # Recover VPN service - if 'ipsec' in qat: - vpn_control('start') - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - apply(c) - except ConfigError as e: - print(e) - vpn_control('restore', force_ipsec=('ipsec' in c)) - exit(1) diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py deleted file mode 100755 index 8184d8415..000000000 --- a/src/conf_mode/interfaces-bonding.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os - -from sys import exit -from netifaces import interfaces -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configdict import leaf_node_changed -from vyos.configdict import is_member -from vyos.configdict import is_source_interface -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_source_interface -from vyos.configverify import verify_vlan_config -from vyos.configverify import verify_vrf -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.dict import dict_search -from vyos.utils.dict import dict_to_paths_values -from vyos.configdict import has_address_configured -from vyos.configdict import has_vrf_configured -from vyos.configdep import set_dependents, call_dependents -from vyos import ConfigError -from vyos import frr -from vyos import airbag -airbag.enable() - -def get_bond_mode(mode): - if mode == 'round-robin': - return 'balance-rr' - elif mode == 'active-backup': - return 'active-backup' - elif mode == 'xor-hash': - return 'balance-xor' - elif mode == 'broadcast': - return 'broadcast' - elif mode == '802.3ad': - return '802.3ad' - elif mode == 'transmit-load-balance': - return 'balance-tlb' - elif mode == 'adaptive-load-balance': - return 'balance-alb' - else: - raise ConfigError(f'invalid bond mode "{mode}"') - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'bonding'] - ifname, bond = get_interface_dict(conf, base) - - # To make our own life easier transfor the list of member interfaces - # into a dictionary - we will use this to add additional information - # later on for each member - if 'member' in bond and 'interface' in bond['member']: - # convert list of member interfaces to a dictionary - bond['member']['interface'] = {k: {} for k in bond['member']['interface']} - - if 'mode' in bond: - bond['mode'] = get_bond_mode(bond['mode']) - - tmp = is_node_changed(conf, base + [ifname, 'mode']) - if tmp: bond['shutdown_required'] = {} - - tmp = is_node_changed(conf, base + [ifname, 'lacp-rate']) - if tmp: bond['shutdown_required'] = {} - - # determine which members have been removed - interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) - # Reset config level to interfaces - old_level = conf.get_level() - conf.set_level(['interfaces']) - - if interfaces_removed: - bond['shutdown_required'] = {} - if 'member' not in bond: - bond['member'] = {} - - tmp = {} - for interface in interfaces_removed: - # if member is deleted from bond, add dependencies to call - # ethernet commit again in apply function - # to apply options under ethernet section - set_dependents('ethernet', conf, interface) - section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists([section, interface, 'disable']): - tmp[interface] = {'disable': ''} - else: - tmp[interface] = {} - - # also present the interfaces to be removed from the bond as dictionary - bond['member']['interface_remove'] = tmp - - # Restore existing config level - conf.set_level(old_level) - - if dict_search('member.interface', bond): - for interface, interface_config in bond['member']['interface'].items(): - - interface_ethernet_config = conf.get_config_dict( - ['interfaces', 'ethernet', interface], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=False, - with_recursive_defaults=False) - - interface_config['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'] = {} - - # 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'] = '' - - 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 - - # 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 - - # 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 - - # bond members must not have an assigned address - tmp = has_address_configured(conf, interface) - if tmp: interface_config['has_address'] = {} - - # bond members must not have a VRF attached - tmp = has_vrf_configured(conf, interface) - if tmp: interface_config['has_vrf'] = {} - return bond - - -def verify(bond): - if 'deleted' in bond: - verify_bridge_delete(bond) - return None - - if 'arp_monitor' in bond: - if 'target' in bond['arp_monitor'] and len(bond['arp_monitor']['target']) > 16: - raise ConfigError('The maximum number of arp-monitor targets is 16') - - if 'interval' in bond['arp_monitor'] and int(bond['arp_monitor']['interval']) > 0: - if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']: - raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \ - 'transmit-load-balance or adaptive-load-balance') - - if 'primary' in bond: - if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: - raise ConfigError('Option primary - mode dependency failed, not' - 'supported in mode {mode}!'.format(**bond)) - - verify_mtu_ipv6(bond) - verify_address(bond) - verify_dhcpv6(bond) - verify_vrf(bond) - verify_mirror_redirect(bond) - - # use common function to verify VLAN configuration - verify_vlan_config(bond) - - bond_name = bond['ifname'] - if dict_search('member.interface', bond): - for interface, interface_config in bond['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bond, ' - - if interface == 'lo': - raise ConfigError('Loopback interface "lo" can not be added to a bond') - - if interface not in interfaces(): - raise ConfigError(error_msg + 'it does not exist!') - - if 'is_bridge_member' in interface_config: - tmp = next(iter(interface_config['is_bridge_member'])) - raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') - - if 'is_bond_member' in interface_config: - tmp = next(iter(interface_config['is_bond_member'])) - raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') - - if 'is_source_interface' in interface_config: - tmp = interface_config['is_source_interface'] - raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!') - - if 'has_address' in interface_config: - raise ConfigError(error_msg + 'it has an address assigned!') - - if 'has_vrf' in interface_config: - raise ConfigError(error_msg + 'it has a VRF assigned!') - - if 'new_added' in interface_config and 'config_paths' in interface_config: - for option_path, option_value in interface_config['config_paths'].items(): - if option_path in EthernetIf.get_bond_member_allowed_options() : - continue - if option_path in BondIf.get_inherit_bond_options(): - continue - raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!') - - if 'primary' in bond: - if bond['primary'] not in bond['member']['interface']: - raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') - - if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: - raise ConfigError('primary interface only works for mode active-backup, ' \ - 'transmit-load-balance or adaptive-load-balance') - - 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) - return None - -def apply(bond): - ifname = bond['ifname'] - b = BondIf(ifname) - if 'deleted' in bond: - # delete interface - b.remove() - else: - b.update(bond) - - if dict_search('member.interface_remove', bond): - try: - call_dependents() - except ConfigError: - 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__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py deleted file mode 100755 index 29991e2da..000000000 --- a/src/conf_mode/interfaces-bridge.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import node_changed -from vyos.configdict import is_member -from vyos.configdict import is_source_interface -from vyos.configdict import has_vlan_subinterface_configured -from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_vrf -from vyos.ifconfig import BridgeIf -from vyos.configdict import has_address_configured -from vyos.configdict import has_vrf_configured -from vyos.configdep import set_dependents -from vyos.configdep import call_dependents -from vyos.utils.dict import dict_search -from vyos import ConfigError - -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'bridge'] - ifname, bridge = get_interface_dict(conf, base) - - # determine which members have been removed - tmp = node_changed(conf, base + [ifname, 'member', 'interface']) - if tmp: - if 'member' in bridge: - bridge['member'].update({'interface_remove' : tmp }) - else: - bridge.update({'member' : {'interface_remove' : tmp }}) - - if dict_search('member.interface', bridge) is not 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}) - - # Check if member interface is already member of a bond - tmp = is_member(conf, interface, 'bonding') - if tmp: bridge['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: bridge['member']['interface'][interface].update({'is_source_interface' : tmp}) - - # Bridge members must not have an assigned address - tmp = has_address_configured(conf, interface) - if tmp: bridge['member']['interface'][interface].update({'has_address' : ''}) - - # Bridge members must not have a VRF attached - tmp = has_vrf_configured(conf, interface) - if tmp: bridge['member']['interface'][interface].update({'has_vrf' : ''}) - - # VLAN-aware bridge members must not have VLAN interface configuration - tmp = has_vlan_subinterface_configured(conf,interface) - if 'enable_vlan' in bridge and tmp: - bridge['member']['interface'][interface].update({'has_vlan' : ''}) - - # When using VXLAN member interfaces that are configured for Single - # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to re-create - # VLAN to VNI mappings if required - if interface.startswith('vxlan'): - set_dependents('vxlan', conf, interface) - - # delete empty dictionary keys - no need to run code paths if nothing is there to do - if 'member' in bridge: - if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: - del bridge['member']['interface'] - - if len(bridge['member']) == 0: - del bridge['member'] - - return bridge - -def verify(bridge): - if 'deleted' in bridge: - return None - - verify_dhcpv6(bridge) - verify_vrf(bridge) - verify_mirror_redirect(bridge) - - ifname = bridge['ifname'] - - if dict_search('member.interface', bridge): - for interface, interface_config in bridge['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bridge, ' - - if interface == 'lo': - raise ConfigError('Loopback interface "lo" can not be added to a bridge') - - if 'is_bridge_member' in interface_config: - tmp = next(iter(interface_config['is_bridge_member'])) - raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') - - if 'is_bond_member' in interface_config: - tmp = next(iter(interface_config['is_bond_member'])) - raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') - - if 'is_source_interface' in interface_config: - tmp = interface_config['is_source_interface'] - raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!') - - if 'has_address' in interface_config: - raise ConfigError(error_msg + 'it has an address assigned!') - - if 'has_vrf' in interface_config: - raise ConfigError(error_msg + 'it has a VRF assigned!') - - if 'enable_vlan' in bridge: - if 'has_vlan' in interface_config: - raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') - - if 'wlan' in interface: - raise ConfigError(error_msg + 'VLAN aware cannot be set!') - else: - for option in ['allowed_vlan', 'native_vlan']: - if option in interface_config: - raise ConfigError('Can not use VLAN options on non VLAN aware bridge') - - if 'enable_vlan' in bridge: - if dict_search('vif.1', bridge): - raise ConfigError(f'VLAN 1 sub interface cannot be set for VLAN aware bridge {ifname}, and VLAN 1 is always the parent interface') - else: - if dict_search('vif', bridge): - raise ConfigError(f'You must first activate "enable-vlan" of {ifname} bridge to use "vif"') - - return None - -def generate(bridge): - return None - -def apply(bridge): - br = BridgeIf(bridge['ifname']) - if 'deleted' in bridge: - # delete interface - br.remove() - else: - br.update(bridge) - - for interface in dict_search('member.interface', bridge) or []: - if interface.startswith('vxlan'): - try: - call_dependents() - except ConfigError: - raise ConfigError('Error in updating VXLAN interface after changing bridge!') - - 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/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py deleted file mode 100755 index db768b94d..000000000 --- a/src/conf_mode/interfaces-dummy.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_vrf -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mirror_redirect -from vyos.ifconfig import DummyIf -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'dummy'] - _, dummy = get_interface_dict(conf, base) - return dummy - -def verify(dummy): - if 'deleted' in dummy: - verify_bridge_delete(dummy) - return None - - verify_vrf(dummy) - verify_address(dummy) - verify_mirror_redirect(dummy) - - return None - -def generate(dummy): - return None - -def apply(dummy): - d = DummyIf(**dummy) - - # Remove dummy interface - if 'deleted' in dummy: - d.remove() - else: - d.update(dummy) - - 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/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py deleted file mode 100755 index 7374a29f7..000000000 --- a/src/conf_mode/interfaces-ethernet.py +++ /dev/null @@ -1,400 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2021 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 . - -import os -import pprint - -from glob import glob -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_dhcpv6 -from vyos.configverify import verify_eapol -from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_mtu -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_vlan_config -from vyos.configverify import verify_vrf -from vyos.configverify import verify_bond_bridge_member -from vyos.ethtool import Ethtool -from vyos.ifconfig import EthernetIf -from vyos.ifconfig import BondIf -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.utils.process import call -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.file import write_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -# XXX: wpa_supplicant works on the source interface -cfg_dir = '/run/wpa_supplicant' -wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' - -def update_bond_options(conf: Config, eth_conf: dict) -> list: - """ - Return list of blocked options if interface is a bond member - :param conf: Config object - :type conf: Config - :param eth_conf: Ethernet config dictionary - :type eth_conf: dict - :return: List of blocked options - :rtype: list - """ - blocked_list = [] - bond_name = list(eth_conf['is_bond_member'].keys())[0] - config_without_defaults = conf.get_config_dict( - ['interfaces', 'ethernet', eth_conf['ifname']], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=False, - with_recursive_defaults=False) - config_with_defaults = conf.get_config_dict( - ['interfaces', 'ethernet', eth_conf['ifname']], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=True, - with_recursive_defaults=True) - bond_config_with_defaults = conf.get_config_dict( - ['interfaces', 'bonding', bond_name], - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=True, - with_recursive_defaults=True) - eth_dict_paths = dict_to_paths_values(config_without_defaults) - eth_path_base = ['interfaces', 'ethernet', eth_conf['ifname']] - - #if option is configured under ethernet section - for option_path, option_value in eth_dict_paths.items(): - bond_option_value = dict_search(option_path, bond_config_with_defaults) - - #If option is allowed for changing then continue - if option_path in EthernetIf.get_bond_member_allowed_options(): - continue - # if option is inherited from bond then set valued from bond interface - if option_path in BondIf.get_inherit_bond_options(): - # If option equals to bond option then do nothing - if option_value == bond_option_value: - continue - else: - # if ethernet has option and bond interface has - # then copy it from bond - if bond_option_value is not None: - if is_node_changed(conf, eth_path_base + option_path.split('.')): - Warning( - f'Cannot apply "{option_path.replace(".", " ")}" to "{option_value}".' \ - f' Interface "{eth_conf["ifname"]}" is a bond member.' \ - f' Option is inherited from bond "{bond_name}"') - dict_set(option_path, bond_option_value, eth_conf) - continue - # if ethernet has option and bond interface does not have - # then delete it form dict and do not apply it - else: - if is_node_changed(conf, eth_path_base + option_path.split('.')): - Warning( - f'Cannot apply "{option_path.replace(".", " ")}".' \ - f' Interface "{eth_conf["ifname"]}" is a bond member.' \ - f' Option is inherited from bond "{bond_name}"') - dict_delete(option_path, eth_conf) - blocked_list.append(option_path) - - # if inherited option is not configured under ethernet section but configured under bond section - for option_path in BondIf.get_inherit_bond_options(): - bond_option_value = dict_search(option_path, bond_config_with_defaults) - if bond_option_value is not None: - if option_path not in eth_dict_paths: - if is_node_changed(conf, eth_path_base + option_path.split('.')): - Warning( - f'Cannot apply "{option_path.replace(".", " ")}" to "{dict_search(option_path, config_with_defaults)}".' \ - f' Interface "{eth_conf["ifname"]}" is a bond member. ' \ - f'Option is inherited from bond "{bond_name}"') - dict_set(option_path, bond_option_value, eth_conf) - eth_conf['bond_blocked_changes'] = blocked_list - return None - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - - # This must be called prior to get_interface_dict(), as this function will - # alter the config level (config.set_level()) - pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - base = ['interfaces', 'ethernet'] - ifname, ethernet = get_interface_dict(conf, base) - if 'is_bond_member' in ethernet: - update_bond_options(conf, ethernet) - - if 'deleted' not in ethernet: - if pki: ethernet['pki'] = pki - - tmp = is_node_changed(conf, base + [ifname, 'speed']) - if tmp: ethernet.update({'speed_duplex_changed': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'duplex']) - if tmp: ethernet.update({'speed_duplex_changed': {}}) - - return ethernet - - - -def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): - """ - Verify speed and duplex - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - :param ethtool: Ethernet object - :type ethtool: Ethtool - """ - if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or - (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): - raise ConfigError( - 'Speed/Duplex missmatch. Must be both auto or manually configured') - - if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': - # We need to verify if the requested speed and duplex setting is - # supported by the underlaying NIC. - speed = ethernet['speed'] - duplex = ethernet['duplex'] - if not ethtool.check_speed_duplex(speed, duplex): - raise ConfigError( - f'Adapter does not support changing speed ' \ - f'and duplex settings to: {speed}/{duplex}!') - - -def verify_flow_control(ethernet: dict, ethtool: Ethtool): - """ - Verify flow control - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - :param ethtool: Ethernet object - :type ethtool: Ethtool - """ - if 'disable_flow_control' in ethernet: - if not ethtool.check_flow_control(): - raise ConfigError( - 'Adapter does not support changing flow-control settings!') - - -def verify_ring_buffer(ethernet: dict, ethtool: Ethtool): - """ - Verify ring buffer - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - :param ethtool: Ethernet object - :type ethtool: Ethtool - """ - if 'ring_buffer' in ethernet: - max_rx = ethtool.get_ring_buffer_max('rx') - if not max_rx: - raise ConfigError( - 'Driver does not support RX ring-buffer configuration!') - - max_tx = ethtool.get_ring_buffer_max('tx') - if not max_tx: - raise ConfigError( - 'Driver does not support TX ring-buffer configuration!') - - rx = dict_search('ring_buffer.rx', ethernet) - if rx and int(rx) > int(max_rx): - raise ConfigError(f'Driver only supports a maximum RX ring-buffer ' \ - f'size of "{max_rx}" bytes!') - - tx = dict_search('ring_buffer.tx', ethernet) - if tx and int(tx) > int(max_tx): - raise ConfigError(f'Driver only supports a maximum TX ring-buffer ' \ - f'size of "{max_tx}" bytes!') - - -def verify_offload(ethernet: dict, ethtool: Ethtool): - """ - Verify offloading capabilities - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - :param ethtool: Ethernet object - :type ethtool: Ethtool - """ - if dict_search('offload.rps', ethernet) != None: - if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'): - raise ConfigError('Interface does not suport RPS!') - driver = ethtool.get_driver_name() - # T3342 - Xen driver requires special treatment - if driver == 'vif': - if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: - raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ - 'for MTU size larger then 1500 bytes') - - -def verify_allowedbond_changes(ethernet: dict): - """ - Verify changed options if interface is in bonding - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - """ - if 'bond_blocked_changes' in ethernet: - for option in ethernet['bond_blocked_changes']: - raise ConfigError(f'Cannot configure "{option.replace(".", " ")}"' \ - f' on interface "{ethernet["ifname"]}".' \ - f' Interface is a bond member') - - -def verify(ethernet): - if 'deleted' in ethernet: - return None - if 'is_bond_member' in ethernet: - verify_bond_member(ethernet) - else: - verify_ethernet(ethernet) - - -def verify_bond_member(ethernet): - """ - Verification function for ethernet interface which is in bonding - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - """ - ifname = ethernet['ifname'] - verify_interface_exists(ifname) - verify_eapol(ethernet) - verify_mirror_redirect(ethernet) - ethtool = Ethtool(ifname) - verify_speed_duplex(ethernet, ethtool) - verify_flow_control(ethernet, ethtool) - verify_ring_buffer(ethernet, ethtool) - verify_offload(ethernet, ethtool) - verify_allowedbond_changes(ethernet) - -def verify_ethernet(ethernet): - """ - Verification function for simple ethernet interface - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - """ - ifname = ethernet['ifname'] - verify_interface_exists(ifname) - verify_mtu(ethernet) - verify_mtu_ipv6(ethernet) - verify_dhcpv6(ethernet) - verify_address(ethernet) - verify_vrf(ethernet) - verify_bond_bridge_member(ethernet) - verify_eapol(ethernet) - verify_mirror_redirect(ethernet) - ethtool = Ethtool(ifname) - # No need to check speed and duplex keys as both have default values. - verify_speed_duplex(ethernet, ethtool) - verify_flow_control(ethernet, ethtool) - verify_ring_buffer(ethernet, ethtool) - verify_offload(ethernet, ethtool) - # use common function to verify VLAN configuration - verify_vlan_config(ethernet) - return None - - -def generate(ethernet): - # render real configuration file once - wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) - - if 'deleted' in ethernet: - # delete configuration on interface removal - if os.path.isfile(wpa_supplicant_conf): - os.unlink(wpa_supplicant_conf) - return None - - if 'eapol' in ethernet: - ifname = ethernet['ifname'] - - render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) - - cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') - cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') - - cert_name = ethernet['eapol']['certificate'] - pki_cert = ethernet['pki']['certificate'][cert_name] - - loaded_pki_cert = load_certificate(pki_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {} - - cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) - - write_file(cert_file_path, - '\n'.join(encode_certificate(c) for c in cert_full_chain)) - write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) - - if 'ca_certificate' in ethernet['eapol']: - ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') - ca_chains = [] - - for ca_cert_name in ethernet['eapol']['ca_certificate']: - pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - ca_chains.append( - '\n'.join(encode_certificate(c) for c in ca_full_chain)) - - write_file(ca_cert_file_path, '\n'.join(ca_chains)) - - return None - -def apply(ethernet): - ifname = ethernet['ifname'] - # take care about EAPoL supplicant daemon - eapol_action='stop' - - e = EthernetIf(ifname) - if 'deleted' in ethernet: - # delete interface - e.remove() - else: - e.update(ethernet) - if 'eapol' in ethernet: - eapol_action='reload-or-restart' - - call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') - -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/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py deleted file mode 100755 index f6694ddde..000000000 --- a/src/conf_mode/interfaces-geneve.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2022 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 . - -from sys import exit -from netifaces import interfaces - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import GeneveIf -from vyos import ConfigError - -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'geneve'] - ifname, geneve = get_interface_dict(conf, base) - - # 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']: - if is_node_changed(conf, base + [ifname, cli_option]): - geneve.update({'rebuild_required': {}}) - - return geneve - -def verify(geneve): - if 'deleted' in geneve: - verify_bridge_delete(geneve) - return None - - verify_mtu_ipv6(geneve) - verify_address(geneve) - verify_bond_bridge_member(geneve) - verify_mirror_redirect(geneve) - - if 'remote' not in geneve: - raise ConfigError('Remote side must be configured') - - if 'vni' not in geneve: - raise ConfigError('VNI must be configured') - - return None - - -def generate(geneve): - return None - -def apply(geneve): - # Check if GENEVE interface already exists - if 'rebuild_required' in geneve or 'delete' in geneve: - if geneve['ifname'] in interfaces(): - g = GeneveIf(geneve['ifname']) - # GENEVE is super picky and the tunnel always needs to be recreated, - # thus we can simply always delete it first. - g.remove() - - if 'deleted' not in geneve: - # Finally create the new interface - g = GeneveIf(**geneve) - g.update(geneve) - - 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/interfaces-input.py b/src/conf_mode/interfaces-input.py deleted file mode 100755 index ad248843d..000000000 --- a/src/conf_mode/interfaces-input.py +++ /dev/null @@ -1,70 +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 . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_mirror_redirect -from vyos.ifconfig import InputIf -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at - least the interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'input'] - _, ifb = get_interface_dict(conf, base) - - return ifb - -def verify(ifb): - if 'deleted' in ifb: - return None - - verify_mirror_redirect(ifb) - return None - -def generate(ifb): - return None - -def apply(ifb): - d = InputIf(ifb['ifname']) - - # Remove input interface - if 'deleted' in ifb: - d.remove() - else: - d.update(ifb) - - 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/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py deleted file mode 100755 index e1db3206e..000000000 --- a/src/conf_mode/interfaces-l2tpv3.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 . - -import os - -from sys import exit -from netifaces import interfaces - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import leaf_node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import L2TPv3If -from vyos.utils.kernel import check_kmod -from vyos.utils.network import is_addr_assigned -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6'] - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'l2tpv3'] - ifname, l2tpv3 = get_interface_dict(conf, base) - - # To delete an l2tpv3 interface we need the current tunnel and session-id - if 'deleted' in l2tpv3: - tmp = leaf_node_changed(conf, base + [ifname, 'tunnel-id']) - # leaf_node_changed() returns a list - l2tpv3.update({'tunnel_id': tmp[0]}) - - tmp = leaf_node_changed(conf, base + [ifname, 'session-id']) - l2tpv3.update({'session_id': tmp[0]}) - - return l2tpv3 - -def verify(l2tpv3): - if 'deleted' in l2tpv3: - verify_bridge_delete(l2tpv3) - return None - - interface = l2tpv3['ifname'] - - for key in ['source_address', 'remote', 'tunnel_id', 'peer_tunnel_id', - 'session_id', 'peer_session_id']: - if key not in l2tpv3: - tmp = key.replace('_', '-') - raise ConfigError(f'Missing mandatory L2TPv3 option: "{tmp}"!') - - if not is_addr_assigned(l2tpv3['source_address']): - raise ConfigError('L2TPv3 source-address address "{source_address}" ' - 'not configured on any interface!'.format(**l2tpv3)) - - verify_mtu_ipv6(l2tpv3) - verify_address(l2tpv3) - verify_bond_bridge_member(l2tpv3) - verify_mirror_redirect(l2tpv3) - return None - -def generate(l2tpv3): - return None - -def apply(l2tpv3): - # Check if L2TPv3 interface already exists - if l2tpv3['ifname'] in interfaces(): - # L2TPv3 is picky when changing tunnels/sessions, thus we can simply - # always delete it first. - l = L2TPv3If(**l2tpv3) - l.remove() - - if 'deleted' not in l2tpv3: - # Finally create the new interface - l = L2TPv3If(**l2tpv3) - l.update(l2tpv3) - - return None - -if __name__ == '__main__': - try: - check_kmod(k_mod) - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces-loopback.py deleted file mode 100755 index 08d34477a..000000000 --- a/src/conf_mode/interfaces-loopback.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 . - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_mirror_redirect -from vyos.ifconfig import LoopbackIf -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'loopback'] - _, loopback = get_interface_dict(conf, base) - return loopback - -def verify(loopback): - verify_mirror_redirect(loopback) - return None - -def generate(loopback): - return None - -def apply(loopback): - l = LoopbackIf(**loopback) - if 'deleted' in loopback: - l.remove() - else: - l.update(loopback) - - 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/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py deleted file mode 100755 index 0a927ac88..000000000 --- a/src/conf_mode/interfaces-macsec.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-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 . - -import os - -from netifaces import interfaces -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.configverify import verify_vrf -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_source_interface -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import MACsecIf -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos.utils.process import is_systemd_service_running -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -# XXX: wpa_supplicant works on the source interface -wpa_suppl_conf = '/run/wpa_supplicant/{source_interface}.conf' - -# Constants -## gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit -GCM_AES_128_LEN: int = 32 -GCM_128_KEY_ERROR = 'gcm-aes-128 requires a 128bit long key!' -## gcm-aes-256 requires a 256bit long key - 64 characters (string) = 32byte = 256bit -GCM_AES_256_LEN: int = 64 -GCM_256_KEY_ERROR = 'gcm-aes-256 requires a 256bit long key!' - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'macsec'] - ifname, macsec = get_interface_dict(conf, base) - - # Check if interface has been removed - if 'deleted' in macsec: - source_interface = conf.return_effective_value(base + [ifname, 'source-interface']) - macsec.update({'source_interface': source_interface}) - - if is_node_changed(conf, base + [ifname, 'security']): - macsec.update({'shutdown_required': {}}) - - if is_node_changed(conf, base + [ifname, 'source_interface']): - macsec.update({'shutdown_required': {}}) - - if 'source_interface' in macsec: - tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) - if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) - - return macsec - - -def verify(macsec): - if 'deleted' in macsec: - verify_bridge_delete(macsec) - return None - - verify_source_interface(macsec) - verify_vrf(macsec) - verify_mtu_ipv6(macsec) - verify_address(macsec) - verify_bond_bridge_member(macsec) - verify_mirror_redirect(macsec) - - if dict_search('security.cipher', macsec) == None: - raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) - - if dict_search('security.encrypt', macsec) != None: - # Check that only static or MKA config is present - if dict_search('security.static', macsec) != None and (dict_search('security.mka.cak', macsec) != None or dict_search('security.mka.ckn', macsec) != None): - raise ConfigError('Only static or MKA can be used!') - - # Logic to check static configuration - if dict_search('security.static', macsec) != None: - # tx-key must be defined - if dict_search('security.static.key', macsec) == None: - raise ConfigError('Static MACsec tx-key must be defined.') - - tx_len = len(dict_search('security.static.key', macsec)) - - if dict_search('security.cipher', macsec) == 'gcm-aes-128' and tx_len != GCM_AES_128_LEN: - raise ConfigError(GCM_128_KEY_ERROR) - - if dict_search('security.cipher', macsec) == 'gcm-aes-256' and tx_len != GCM_AES_256_LEN: - raise ConfigError(GCM_256_KEY_ERROR) - - # Make sure at least one peer is defined - if 'peer' not in macsec['security']['static']: - raise ConfigError('Must have at least one peer defined for static MACsec') - - # For every enabled peer, make sure a MAC and rx-key is defined - for peer, peer_config in macsec['security']['static']['peer'].items(): - if 'disable' not in peer_config and ('mac' not in peer_config or 'key' not in peer_config): - raise ConfigError('Every enabled MACsec static peer must have a MAC address and rx-key defined.') - - # check rx-key length against cipher suite - rx_len = len(peer_config['key']) - - if dict_search('security.cipher', macsec) == 'gcm-aes-128' and rx_len != GCM_AES_128_LEN: - raise ConfigError(GCM_128_KEY_ERROR) - - if dict_search('security.cipher', macsec) == 'gcm-aes-256' and rx_len != GCM_AES_256_LEN: - raise ConfigError(GCM_256_KEY_ERROR) - - # Logic to check MKA configuration - else: - if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: - raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') - - cak_len = len(dict_search('security.mka.cak', macsec)) - - if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != GCM_AES_128_LEN: - raise ConfigError(GCM_128_KEY_ERROR) - - elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != GCM_AES_256_LEN: - raise ConfigError(GCM_256_KEY_ERROR) - - if 'source_interface' in macsec: - # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad - # and 802.1q) - we need to check the underlaying MTU if our configured - # MTU is at least 40 bytes less then the MTU of our physical interface. - lower_mtu = Interface(macsec['source_interface']).get_mtu() - if lower_mtu < (int(macsec['mtu']) + 40): - raise ConfigError('MACsec overhead does not fit into underlaying device MTU,\n' \ - f'{lower_mtu} bytes is too small!') - - return None - - -def generate(macsec): - # Only generate wpa_supplicant config if using MKA - if dict_search('security.mka.cak', macsec): - render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec) - return None - - -def apply(macsec): - systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec) - - # Remove macsec interface on deletion or mandatory parameter change - if 'deleted' in macsec or 'shutdown_required' in macsec: - call(f'systemctl stop {systemd_service}') - - if macsec['ifname'] in interfaces(): - tmp = MACsecIf(macsec['ifname']) - tmp.remove() - - if 'deleted' in macsec: - # delete configuration on interface removal - if os.path.isfile(wpa_suppl_conf.format(**macsec)): - os.unlink(wpa_suppl_conf.format(**macsec)) - - return None - - # It is safe to "re-create" the interface always, there is a sanity - # check that the interface will only be create if its non existent - i = MACsecIf(**macsec) - i.update(macsec) - - # Only reload/restart if using MKA - if dict_search('security.mka.cak', macsec): - if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: - call(f'systemctl reload-or-restart {systemd_service}') - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py deleted file mode 100755 index bdeb44837..000000000 --- a/src/conf_mode/interfaces-openvpn.py +++ /dev/null @@ -1,732 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os -import re -import tempfile - -from cryptography.hazmat.primitives.asymmetric import ec -from glob import glob -from sys import exit -from ipaddress import IPv4Address -from ipaddress import IPv4Network -from ipaddress import IPv6Address -from ipaddress import IPv6Network -from ipaddress import summarize_address_range -from netifaces import interfaces -from secrets import SystemRandom -from shutil import rmtree - -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.configverify import verify_vrf -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import VTunIf -from vyos.pki import load_dh_parameters -from vyos.pki import load_private_key -from vyos.pki import sort_ca_chain -from vyos.pki import verify_ca_chain -from vyos.pki import wrap_certificate -from vyos.pki import wrap_crl -from vyos.pki import wrap_dh_parameters -from vyos.pki import wrap_openvpn_key -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 -from vyos.utils.dict import dict_search -from vyos.utils.dict import dict_search_args -from vyos.utils.list import is_list_equal -from vyos.utils.file import makedir -from vyos.utils.file import read_file -from vyos.utils.file import write_file -from vyos.utils.kernel import check_kmod -from vyos.utils.kernel import unload_kmod -from vyos.utils.process import call -from vyos.utils.permission import chown -from vyos.utils.process import cmd -from vyos.utils.network import is_addr_assigned - -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -user = 'openvpn' -group = 'openvpn' - -cfg_dir = '/run/openvpn' -cfg_file = '/run/openvpn/{ifname}.conf' -otp_path = '/config/auth/openvpn' -otp_file = '/config/auth/openvpn/{ifname}-otp-secrets' -secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567') -service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf' - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'openvpn'] - - ifname, openvpn = get_interface_dict(conf, base) - openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) - - if 'deleted' in openvpn: - return openvpn - - openvpn['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - if is_node_changed(conf, base + [ifname, 'openvpn-option']): - openvpn.update({'restart_required': {}}) - if is_node_changed(conf, base + [ifname, 'enable-dco']): - openvpn.update({'restart_required': {}}) - - # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict' - # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there. - tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) - - # We have to cleanup the config dict, as default values could enable features - # which are not explicitly enabled on the CLI. Example: server mfa totp - # originate comes with defaults, which will enable the - # totp plugin, even when not set via CLI so we - # need to check this first and drop those keys - if dict_search('server.mfa.totp', tmp) == None: - del openvpn['server']['mfa'] - - # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all - # OpenVPN interfaces. Check if DCO is used by any other interface instance. - tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - for interface, interface_config in tmp.items(): - # If one interface has DCO configured, enable it. No need to further check - # all other OpenVPN interfaces. We must use a dedicated key to indicate - # the Kernel module must be loaded or not. The per interface "offload.dco" - # key is required per OpenVPN interface instance. - if dict_search('offload.dco', interface_config) != None: - openvpn['module_load_dco'] = {} - break - - return openvpn - -def is_ec_private_key(pki, cert_name): - if not pki or 'certificate' not in pki: - return False - if cert_name not in pki['certificate']: - return False - - pki_cert = pki['certificate'][cert_name] - if 'private' not in pki_cert or 'key' not in pki_cert['private']: - return False - - key = load_private_key(pki_cert['private']['key']) - return isinstance(key, ec.EllipticCurvePrivateKey) - -def verify_pki(openvpn): - pki = openvpn['pki'] - interface = openvpn['ifname'] - mode = openvpn['mode'] - shared_secret_key = dict_search_args(openvpn, 'shared_secret_key') - tls = dict_search_args(openvpn, 'tls') - - if not bool(shared_secret_key) ^ bool(tls): # xor check if only one is set - raise ConfigError('Must specify only one of "shared-secret-key" and "tls"') - - if mode in ['server', 'client'] and not tls: - raise ConfigError('Must specify "tls" for server and client modes') - - if not pki: - raise ConfigError('PKI is not configured') - - if shared_secret_key: - if not dict_search_args(pki, 'openvpn', 'shared_secret'): - raise ConfigError('There are no openvpn shared-secrets in PKI configuration') - - if shared_secret_key not in pki['openvpn']['shared_secret']: - raise ConfigError(f'Invalid shared-secret on openvpn interface {interface}') - - # If PSK settings are correct, warn about its deprecation - DeprecationWarning("OpenVPN shared-secret support will be removed in future VyOS versions.\n\ - Please migrate your site-to-site tunnels to TLS.\n\ - You can use self-signed certificates with peer fingerprint verification, consult the documentation for details.") - - if tls: - if (mode in ['server', 'client']) and ('ca_certificate' not in tls): - raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface},\ - it is required in server and client modes') - else: - if ('ca_certificate' not in tls) and ('peer_fingerprint' not in tls): - raise ConfigError('Either "tls ca-certificate" or "tls peer-fingerprint" is required\ - on openvpn interface {interface} in site-to-site mode') - - if 'ca_certificate' in tls: - for ca_name in tls['ca_certificate']: - if ca_name not in pki['ca']: - raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') - - if len(tls['ca_certificate']) > 1: - sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) - if not verify_ca_chain(sorted_chain, pki['ca']): - raise ConfigError(f'CA certificates are not a valid chain') - - if mode != 'client' and 'auth_key' not in tls: - if 'certificate' not in tls: - raise ConfigError(f'Missing "tls certificate" on openvpn interface {interface}') - - if 'certificate' in tls: - if tls['certificate'] not in pki['certificate']: - raise ConfigError(f'Invalid certificate on openvpn interface {interface}') - - if dict_search_args(pki, 'certificate', tls['certificate'], 'private', 'password_protected') is not None: - raise ConfigError(f'Cannot use encrypted private key on openvpn interface {interface}') - - if 'dh_params' in tls: - pki_dh = pki['dh'][tls['dh_params']] - dh_params = load_dh_parameters(pki_dh['parameters']) - dh_numbers = dh_params.parameter_numbers() - dh_bits = dh_numbers.p.bit_length() - - if dh_bits < 2048: - raise ConfigError(f'Minimum DH key-size is 2048 bits') - - - if 'auth_key' in tls or 'crypt_key' in tls: - if not dict_search_args(pki, 'openvpn', 'shared_secret'): - raise ConfigError('There are no openvpn shared-secrets in PKI configuration') - - if 'auth_key' in tls: - if tls['auth_key'] not in pki['openvpn']['shared_secret']: - raise ConfigError(f'Invalid auth-key on openvpn interface {interface}') - - if 'crypt_key' in tls: - if tls['crypt_key'] not in pki['openvpn']['shared_secret']: - raise ConfigError(f'Invalid crypt-key on openvpn interface {interface}') - -def verify(openvpn): - if 'deleted' in openvpn: - # remove totp secrets file if totp is not configured - if os.path.isfile(otp_file.format(**openvpn)): - os.remove(otp_file.format(**openvpn)) - - verify_bridge_delete(openvpn) - return None - - if 'mode' not in openvpn: - raise ConfigError('Must specify OpenVPN operation mode!') - - # - # OpenVPN client mode - VERIFY - # - if openvpn['mode'] == 'client': - if 'local_port' in openvpn: - raise ConfigError('Cannot specify "local-port" in client mode') - - if 'local_host' in openvpn: - raise ConfigError('Cannot specify "local-host" in client mode') - - if 'remote_host' not in openvpn: - raise ConfigError('Must specify "remote-host" in client mode') - - if openvpn['protocol'] == 'tcp-passive': - raise ConfigError('Protocol "tcp-passive" is not valid in client mode') - - if dict_search('tls.dh_params', openvpn): - raise ConfigError('Cannot specify "tls dh-params" in client mode') - - # - # OpenVPN site-to-site - VERIFY - # - elif openvpn['mode'] == 'site-to-site': - if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn: - raise ConfigError('Must specify "local-address" or add interface to bridge') - - if 'local_address' in openvpn: - if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: - raise ConfigError('Only one IPv4 local-address can be specified') - - if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: - raise ConfigError('Only one IPv6 local-address can be specified') - - if openvpn['device_type'] == 'tun': - if 'remote_address' not in openvpn: - raise ConfigError('Must specify "remote-address"') - - if 'remote_address' in openvpn: - if len([addr for addr in openvpn['remote_address'] if is_ipv4(addr)]) > 1: - raise ConfigError('Only one IPv4 remote-address can be specified') - - if len([addr for addr in openvpn['remote_address'] if is_ipv6(addr)]) > 1: - raise ConfigError('Only one IPv6 remote-address can be specified') - - if not 'local_address' in openvpn: - raise ConfigError('"remote-address" requires "local-address"') - - v4loAddr = [addr for addr in openvpn['local_address'] if is_ipv4(addr)] - v4remAddr = [addr for addr in openvpn['remote_address'] if is_ipv4(addr)] - if v4loAddr and not v4remAddr: - raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address"') - elif v4remAddr and not v4loAddr: - raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"') - - v6remAddr = [addr for addr in openvpn['remote_address'] if is_ipv6(addr)] - v6loAddr = [addr for addr in openvpn['local_address'] if is_ipv6(addr)] - if v6loAddr and not v6remAddr: - raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"') - elif v6remAddr and not v6loAddr: - raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') - - if is_list_equal(v4loAddr, v4remAddr) or is_list_equal(v6loAddr, v6remAddr): - raise ConfigError('"local-address" and "remote-address" cannot be the same') - - if dict_search('local_host', openvpn) in dict_search('local_address', openvpn): - raise ConfigError('"local-address" cannot be the same as "local-host"') - - if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn): - raise ConfigError('"remote-address" and "remote-host" can not be the same') - - if openvpn['device_type'] == 'tap' and 'local_address' in openvpn: - # we can only have one local_address, this is ensured above - v4addr = None - for laddr in openvpn['local_address']: - if is_ipv4(laddr): - v4addr = laddr - break - - if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]: - raise ConfigError('Must specify IPv4 "subnet-mask" for local-address') - - if dict_search('encryption.ncp_ciphers', openvpn): - raise ConfigError('NCP ciphers can only be used in client or server mode') - - else: - # checks for client-server or site-to-site bridged - if 'local_address' in openvpn or 'remote_address' in openvpn: - raise ConfigError('Cannot specify "local-address" or "remote-address" ' \ - 'in client/server or bridge mode') - - # - # OpenVPN server mode - VERIFY - # - if openvpn['mode'] == 'server': - if openvpn['protocol'] == 'tcp-active': - raise ConfigError('Protocol "tcp-active" is not valid in server mode') - - if dict_search('authentication.username', openvpn) or dict_search('authentication.password', openvpn): - raise ConfigError('Cannot specify "authentication" in server mode') - - if 'remote_port' in openvpn: - raise ConfigError('Cannot specify "remote-port" in server mode') - - if 'remote_host' in openvpn: - raise ConfigError('Cannot specify "remote-host" in server mode') - - tmp = dict_search('server.subnet', openvpn) - if tmp: - v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)]) - v6_subnets = len([subnet for subnet in tmp if is_ipv6(subnet)]) - if v4_subnets > 1: - raise ConfigError('Cannot specify more than 1 IPv4 server subnet') - if v6_subnets > 1: - raise ConfigError('Cannot specify more than 1 IPv6 server subnet') - - for subnet in tmp: - if is_ipv4(subnet): - subnet = IPv4Network(subnet) - - if openvpn['device_type'] == 'tun' and subnet.prefixlen > 29: - raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported') - elif openvpn['device_type'] == 'tap' and subnet.prefixlen > 30: - raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported') - - for client in (dict_search('client', openvpn) or []): - if client['ip'] and not IPv4Address(client['ip'][0]) in subnet: - raise ConfigError(f'Client "{client["name"]}" IP {client["ip"][0]} not in server subnet {subnet}') - - else: - if 'is_bridge_member' not in openvpn: - raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode') - - if hasattr(dict_search('server.client', openvpn), '__iter__'): - for client_k, client_v in dict_search('server.client', openvpn).items(): - if (client_v.get('ip') and len(client_v['ip']) > 1) or (client_v.get('ipv6_ip') and len(client_v['ipv6_ip']) > 1): - raise ConfigError(f'Server client "{client_k}": cannot specify more than 1 IPv4 and 1 IPv6 IP') - - if dict_search('server.client_ip_pool', openvpn): - if not (dict_search('server.client_ip_pool.start', openvpn) and dict_search('server.client_ip_pool.stop', openvpn)): - raise ConfigError('Server client-ip-pool requires both start and stop addresses') - else: - v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn)) - v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn)) - if v4PoolStart > v4PoolStop: - raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') - - v4PoolSize = int(v4PoolStop) - int(v4PoolStart) - if v4PoolSize >= 65536: - raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') - - v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) - for client in (dict_search('client', openvpn) or []): - if client['ip']: - for v4PoolNet in v4PoolNets: - if IPv4Address(client['ip'][0]) in v4PoolNet: - print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.') - # configuring a client_ip_pool will set 'server ... nopool' which is currently incompatible with 'server-ipv6' (probably to be fixed upstream) - for subnet in (dict_search('server.subnet', openvpn) or []): - if is_ipv6(subnet): - raise ConfigError(f'Setting client-ip-pool is incompatible having an IPv6 server subnet.') - - for subnet in (dict_search('server.subnet', openvpn) or []): - if is_ipv6(subnet): - tmp = dict_search('client_ipv6_pool.base', openvpn) - if tmp: - if not dict_search('server.client_ip_pool', openvpn): - raise ConfigError('IPv6 server pool requires an IPv4 server pool') - - if int(tmp.split('/')[1]) >= 112: - raise ConfigError('IPv6 server pool must be larger than /112') - - # - # todo - weird logic - # - v6PoolStart = IPv6Address(tmp) - v6PoolStop = IPv6Network((v6PoolStart, openvpn['server_ipv6_pool_prefixlen']), strict=False)[-1] # don't remove the parentheses, it's a 2-tuple - v6PoolSize = int(v6PoolStop) - int(v6PoolStart) if int(openvpn['server_ipv6_pool_prefixlen']) > 96 else 65536 - if v6PoolSize < v4PoolSize: - raise ConfigError(f'IPv6 server pool must be at least as large as the IPv4 pool (current sizes: IPv6={v6PoolSize} IPv4={v4PoolSize})') - - v6PoolNets = list(summarize_address_range(v6PoolStart, v6PoolStop)) - for client in (dict_search('client', openvpn) or []): - if client['ipv6_ip']: - for v6PoolNet in v6PoolNets: - if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet: - print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.') - - # add mfa users to the file the mfa plugin uses - if dict_search('server.mfa.totp', openvpn): - user_data = '' - if not os.path.isfile(otp_file.format(**openvpn)): - write_file(otp_file.format(**openvpn), user_data, - user=user, group=group, mode=0o644) - - ovpn_users = read_file(otp_file.format(**openvpn)) - for client in (dict_search('server.client', openvpn) or []): - exists = None - for ovpn_user in ovpn_users.split('\n'): - if re.search('^' + client + ' ', ovpn_user): - user_data += f'{ovpn_user}\n' - exists = 'true' - - if not exists: - random = SystemRandom() - totp_secret = ''.join(random.choice(secret_chars) for _ in range(16)) - user_data += f'{client} otp totp:sha1:base32:{totp_secret}::xxx *\n' - - write_file(otp_file.format(**openvpn), user_data, - user=user, group=group, mode=0o644) - - else: - # checks for both client and site-to-site go here - if dict_search('server.reject_unconfigured_clients', openvpn): - raise ConfigError('Option reject-unconfigured-clients only supported in server mode') - - if 'replace_default_route' in openvpn and 'remote_host' not in openvpn: - raise ConfigError('Cannot set "replace-default-route" without "remote-host"') - - # - # OpenVPN common verification section - # not depending on any operation mode - # - - # verify specified IP address is present on any interface on this system - if 'local_host' in openvpn: - if not is_addr_assigned(openvpn['local_host']): - print('local-host IP address "{local_host}" not assigned' \ - ' to any interface'.format(**openvpn)) - - # TCP active - if openvpn['protocol'] == 'tcp-active': - if 'local_port' in openvpn: - raise ConfigError('Cannot specify "local-port" with "tcp-active"') - - if 'remote_host' not in openvpn: - raise ConfigError('Must specify "remote-host" with "tcp-active"') - - # - # TLS/encryption - # - if 'shared_secret_key' in openvpn: - if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']: - raise ConfigError('GCM encryption with shared-secret-key not supported') - - if 'tls' in openvpn: - if {'auth_key', 'crypt_key'} <= set(openvpn['tls']): - raise ConfigError('TLS auth and crypt keys are mutually exclusive') - - tmp = dict_search('tls.role', openvpn) - if tmp: - if openvpn['mode'] in ['client', 'server']: - if not dict_search('tls.auth_key', openvpn): - raise ConfigError('Cannot specify "tls role" in client-server mode') - - if tmp == 'active': - if openvpn['protocol'] == 'tcp-passive': - raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') - - if dict_search('tls.dh_params', openvpn): - raise ConfigError('Cannot specify "tls dh-params" when "tls role" is "active"') - - elif tmp == 'passive': - if openvpn['protocol'] == 'tcp-active': - raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') - - if 'certificate' in openvpn['tls'] and is_ec_private_key(openvpn['pki'], openvpn['tls']['certificate']): - if 'dh_params' in openvpn['tls']: - print('Warning: using dh-params and EC keys simultaneously will ' \ - 'lead to DH ciphers being used instead of ECDH') - - if dict_search('encryption.cipher', openvpn) == 'none': - print('Warning: "encryption none" was specified!') - print('No encryption will be performed and data is transmitted in ' \ - 'plain text over the network!') - - verify_pki(openvpn) - - # - # Auth user/pass - # - if (dict_search('authentication.username', openvpn) and not - dict_search('authentication.password', openvpn)): - raise ConfigError('Password for authentication is missing') - - if (dict_search('authentication.password', openvpn) and not - dict_search('authentication.username', openvpn)): - raise ConfigError('Username for authentication is missing') - - verify_vrf(openvpn) - verify_bond_bridge_member(openvpn) - verify_mirror_redirect(openvpn) - - return None - -def generate_pki_files(openvpn): - pki = openvpn['pki'] - if not pki: - return None - - interface = openvpn['ifname'] - shared_secret_key = dict_search_args(openvpn, 'shared_secret_key') - tls = dict_search_args(openvpn, 'tls') - - if shared_secret_key: - pki_key = pki['openvpn']['shared_secret'][shared_secret_key] - key_path = os.path.join(cfg_dir, f'{interface}_shared.key') - write_file(key_path, wrap_openvpn_key(pki_key['key']), - user=user, group=group) - - if tls: - if 'ca_certificate' in tls: - cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') - crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') - - if os.path.exists(cert_path): - os.unlink(cert_path) - - if os.path.exists(crl_path): - os.unlink(crl_path) - - for cert_name in sort_ca_chain(tls['ca_certificate'], pki['ca']): - pki_ca = pki['ca'][cert_name] - - if 'certificate' in pki_ca: - write_file(cert_path, wrap_certificate(pki_ca['certificate']) + "\n", - user=user, group=group, mode=0o600, append=True) - - if 'crl' in pki_ca: - for crl in pki_ca['crl']: - write_file(crl_path, wrap_crl(crl) + "\n", user=user, group=group, - mode=0o600, append=True) - - openvpn['tls']['crl'] = True - - if 'certificate' in tls: - cert_name = tls['certificate'] - pki_cert = pki['certificate'][cert_name] - - if 'certificate' in pki_cert: - cert_path = os.path.join(cfg_dir, f'{interface}_cert.pem') - write_file(cert_path, wrap_certificate(pki_cert['certificate']), - user=user, group=group, mode=0o600) - - if 'private' in pki_cert and 'key' in pki_cert['private']: - key_path = os.path.join(cfg_dir, f'{interface}_cert.key') - write_file(key_path, wrap_private_key(pki_cert['private']['key']), - user=user, group=group, mode=0o600) - - openvpn['tls']['private_key'] = True - - if 'dh_params' in tls: - dh_name = tls['dh_params'] - pki_dh = pki['dh'][dh_name] - - if 'parameters' in pki_dh: - dh_path = os.path.join(cfg_dir, f'{interface}_dh.pem') - write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']), - user=user, group=group, mode=0o600) - - if 'auth_key' in tls: - key_name = tls['auth_key'] - pki_key = pki['openvpn']['shared_secret'][key_name] - - if 'key' in pki_key: - key_path = os.path.join(cfg_dir, f'{interface}_auth.key') - write_file(key_path, wrap_openvpn_key(pki_key['key']), - user=user, group=group, mode=0o600) - - if 'crypt_key' in tls: - key_name = tls['crypt_key'] - pki_key = pki['openvpn']['shared_secret'][key_name] - - if 'key' in pki_key: - key_path = os.path.join(cfg_dir, f'{interface}_crypt.key') - write_file(key_path, wrap_openvpn_key(pki_key['key']), - user=user, group=group, mode=0o600) - - -def generate(openvpn): - interface = openvpn['ifname'] - directory = os.path.dirname(cfg_file.format(**openvpn)) - openvpn['plugin_dir'] = '/usr/lib/openvpn' - # create base config directory on demand - makedir(directory, user, group) - # enforce proper permissions on /run/openvpn - chown(directory, user, group) - - # we can't know in advance which clients have been removed, - # thus all client configs will be removed and re-added on demand - ccd_dir = os.path.join(directory, 'ccd', interface) - if os.path.isdir(ccd_dir): - rmtree(ccd_dir, ignore_errors=True) - - # Remove systemd directories with overrides - service_dir = os.path.dirname(service_file.format(**openvpn)) - if os.path.isdir(service_dir): - rmtree(service_dir, ignore_errors=True) - - if 'deleted' in openvpn or 'disable' in openvpn: - return None - - # create client config directory on demand - makedir(ccd_dir, user, group) - - # Fix file permissons for keys - generate_pki_files(openvpn) - - # Generate User/Password authentication file - if 'authentication' in openvpn: - render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.j2', openvpn, - user=user, group=group, permission=0o600) - else: - # delete old auth file if present - if os.path.isfile(openvpn['auth_user_pass_file']): - os.remove(openvpn['auth_user_pass_file']) - - # Generate client specific configuration - server_client = dict_search_args(openvpn, 'server', 'client') - if server_client: - for client, client_config in server_client.items(): - client_file = os.path.join(ccd_dir, client) - - # Our client need's to know its subnet mask ... - client_config['server_subnet'] = dict_search('server.subnet', openvpn) - - render(client_file, 'openvpn/client.conf.j2', client_config, - user=user, group=group) - - # we need to support quoting of raw parameters from OpenVPN CLI - # see https://vyos.dev/T1632 - render(cfg_file.format(**openvpn), 'openvpn/server.conf.j2', openvpn, - formater=lambda _: _.replace(""", '"'), user=user, group=group) - - # Render 20-override.conf for OpenVPN service - render(service_file.format(**openvpn), 'openvpn/service-override.conf.j2', openvpn, - formater=lambda _: _.replace(""", '"'), user=user, group=group) - # Reload systemd services config to apply an override - call(f'systemctl daemon-reload') - - return None - -def apply(openvpn): - interface = openvpn['ifname'] - - # Do some cleanup when OpenVPN is disabled/deleted - if 'deleted' in openvpn or 'disable' in openvpn: - call(f'systemctl stop openvpn@{interface}.service') - for cleanup_file in glob(f'/run/openvpn/{interface}.*'): - if os.path.isfile(cleanup_file): - os.unlink(cleanup_file) - - if interface in interfaces(): - VTunIf(interface).remove() - - # dynamically load/unload DCO Kernel extension if requested - dco_module = 'ovpn_dco_v2' - if 'module_load_dco' in openvpn: - check_kmod(dco_module) - else: - unload_kmod(dco_module) - - # Now bail out early if interface is disabled or got deleted - if 'deleted' in openvpn or 'disable' in openvpn: - return None - - # verify specified IP address is present on any interface on this system - # Allow to bind service to nonlocal address, if it virtaual-vrrp address - # or if address will be assign later - if 'local_host' in openvpn: - if not is_addr_assigned(openvpn['local_host']): - cmd('sysctl -w net.ipv4.ip_nonlocal_bind=1') - - # 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') - - o = VTunIf(**openvpn) - o.update(openvpn) - - 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/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py deleted file mode 100755 index 42f084309..000000000 --- a/src/conf_mode/interfaces-pppoe.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2021 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 . - -import os - -from sys import exit -from copy import deepcopy -from netifaces import interfaces - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configdict import get_pppoe_interfaces -from vyos.configverify import verify_authentication -from vyos.configverify import verify_source_interface -from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_vrf -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.ifconfig import PPPoEIf -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'pppoe'] - ifname, pppoe = get_interface_dict(conf, base) - - # We should only terminate the PPPoE session if critical parameters change. - # All parameters that can be changed on-the-fly (like interface description) - # should not lead to a reconnect! - for options in ['access-concentrator', 'connect-on-demand', 'service-name', - 'source-interface', 'vrf', 'no-default-route', - 'authentication', 'host_uniq']: - if is_node_changed(conf, base + [ifname, options]): - pppoe.update({'shutdown_required': {}}) - # bail out early - no need to further process other nodes - break - - if 'deleted' not in pppoe: - # We always set the MRU value to the MTU size. This code path only re-creates - # the old behavior if MRU is not set on the CLI. - if 'mru' not in pppoe: - pppoe['mru'] = pppoe['mtu'] - - return pppoe - -def verify(pppoe): - if 'deleted' in pppoe: - # bail out early - return None - - verify_source_interface(pppoe) - verify_authentication(pppoe) - verify_vrf(pppoe) - verify_mtu_ipv6(pppoe) - verify_mirror_redirect(pppoe) - - if {'connect_on_demand', 'vrf'} <= set(pppoe): - raise ConfigError('On-demand dialing and VRF can not be used at the same time') - - # both MTU and MRU have default values, thus we do not need to check - # if the key exists - if int(pppoe['mru']) > int(pppoe['mtu']): - raise ConfigError('PPPoE MRU needs to be lower then MTU!') - - return None - -def generate(pppoe): - # set up configuration file path variables where our templates will be - # rendered into - ifname = pppoe['ifname'] - config_pppoe = f'/etc/ppp/peers/{ifname}' - - if 'deleted' in pppoe or 'disable' in pppoe: - if os.path.exists(config_pppoe): - os.unlink(config_pppoe) - - return None - - # Create PPP configuration files - render(config_pppoe, 'pppoe/peer.j2', pppoe, permission=0o640) - - return None - -def apply(pppoe): - ifname = pppoe['ifname'] - if 'deleted' in pppoe or 'disable' in pppoe: - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = PPPoEIf(ifname) - p.remove() - call(f'systemctl stop ppp@{ifname}.service') - return None - - # reconnect should only be necessary when certain config options change, - # like ACS name, authentication ... (see get_config() for details) - if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or - 'shutdown_required' in pppoe): - - # cleanup system (e.g. FRR routes first) - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = PPPoEIf(ifname) - p.remove() - - call(f'systemctl restart ppp@{ifname}.service') - # When interface comes "live" a hook is called: - # /etc/ppp/ip-up.d/99-vyos-pppoe-callback - # which triggers PPPoEIf.update() - else: - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = PPPoEIf(ifname) - p.update(pppoe) - - 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/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py deleted file mode 100755 index dce5c2358..000000000 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2022 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 . - -from sys import exit -from netifaces import interfaces - -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.configdict import is_node_changed -from vyos.configverify import verify_vrf -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_source_interface -from vyos.configverify import verify_vlan_config -from vyos.configverify import verify_mtu_parent -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import MACVLANIf -from vyos import ConfigError - -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at - least the interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'pseudo-ethernet'] - ifname, peth = get_interface_dict(conf, base) - - mode = is_node_changed(conf, ['mode']) - if mode: peth.update({'shutdown_required' : {}}) - - if is_node_changed(conf, base + [ifname, 'mode']): - peth.update({'rebuild_required': {}}) - - if 'source_interface' in peth: - _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], - peth['source_interface']) - # test if source-interface is maybe already used by another interface - tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) - if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) - - return peth - -def verify(peth): - if 'deleted' in peth: - verify_bridge_delete(peth) - return None - - verify_source_interface(peth) - verify_vrf(peth) - verify_address(peth) - verify_mtu_parent(peth, peth['parent']) - verify_mirror_redirect(peth) - # use common function to verify VLAN configuration - verify_vlan_config(peth) - - return None - -def generate(peth): - return None - -def apply(peth): - # Check if the MACVLAN interface already exists - if 'rebuild_required' in peth or 'deleted' in peth: - if peth['ifname'] in interfaces(): - p = MACVLANIf(peth['ifname']) - # MACVLAN is always needs to be recreated, - # thus we can simply always delete it first. - p.remove() - - if 'deleted' not in peth: - p = MACVLANIf(**peth) - p.update(peth) - - 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/interfaces-sstpc.py b/src/conf_mode/interfaces-sstpc.py deleted file mode 100755 index b588910dc..000000000 --- a/src/conf_mode/interfaces-sstpc.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2022 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 . - -import os -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.configverify import verify_authentication -from vyos.configverify import verify_vrf -from vyos.ifconfig import SSTPCIf -from vyos.pki import encode_certificate -from vyos.pki import find_chain -from vyos.pki import load_certificate -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos.utils.process import is_systemd_service_running -from vyos.utils.file import write_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'sstpc'] - ifname, sstpc = get_interface_dict(conf, base) - - # We should only terminate the SSTP client session if critical parameters - # change. All parameters that can be changed on-the-fly (like interface - # description) should not lead to a reconnect! - for options in ['authentication', 'no_peer_dns', 'no_default_route', - 'server', 'ssl']: - if is_node_changed(conf, base + [ifname, options]): - sstpc.update({'shutdown_required': {}}) - # bail out early - no need to further process other nodes - break - - # Load PKI certificates for later processing - sstpc['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - return sstpc - -def verify(sstpc): - if 'deleted' in sstpc: - return None - - verify_authentication(sstpc) - verify_vrf(sstpc) - - if not dict_search('server', sstpc): - raise ConfigError('Remote SSTP server must be specified!') - - if not dict_search('ssl.ca_certificate', sstpc): - raise ConfigError('Missing mandatory CA certificate!') - - return None - -def generate(sstpc): - ifname = sstpc['ifname'] - config_sstpc = f'/etc/ppp/peers/{ifname}' - - sstpc['ca_file_path'] = f'/run/sstpc/{ifname}_ca-cert.pem' - - if 'deleted' in sstpc: - for file in [sstpc['ca_file_path'], config_sstpc]: - if os.path.exists(file): - os.unlink(file) - return None - - ca_name = sstpc['ssl']['ca_certificate'] - pki_ca_cert = sstpc['pki']['ca'][ca_name] - - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in sstpc['pki']['ca'].values()} if 'ca' in sstpc['pki'] else {} - - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - - write_file(sstpc['ca_file_path'], '\n'.join(encode_certificate(c) for c in ca_full_chain)) - render(config_sstpc, 'sstp-client/peer.j2', sstpc, permission=0o640) - - return None - -def apply(sstpc): - ifname = sstpc['ifname'] - if 'deleted' in sstpc or 'disable' in sstpc: - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = SSTPCIf(ifname) - p.remove() - call(f'systemctl stop ppp@{ifname}.service') - return None - - # reconnect should only be necessary when specific options change, - # like server, authentication ... (see get_config() for details) - if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or - 'shutdown_required' in sstpc): - - # cleanup system (e.g. FRR routes first) - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = SSTPCIf(ifname) - p.remove() - - call(f'systemctl restart ppp@{ifname}.service') - # When interface comes "live" a hook is called: - # /etc/ppp/ip-up.d/96-vyos-sstpc-callback - # which triggers SSTPCIf.update() - else: - if os.path.isdir(f'/sys/class/net/{ifname}'): - p = SSTPCIf(ifname) - p.update(sstpc) - - 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/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py deleted file mode 100755 index 91aed9cc3..000000000 --- a/src/conf_mode/interfaces-tunnel.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 yOS 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 . - -import os - -from sys import exit -from netifaces import interfaces - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_vrf -from vyos.configverify import verify_tunnel -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import Interface -from vyos.ifconfig import Section -from vyos.ifconfig import TunnelIf -from vyos.utils.network import get_interface_config -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least - the interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'tunnel'] - ifname, tunnel = get_interface_dict(conf, base) - - if 'deleted' not in tunnel: - tmp = is_node_changed(conf, base + [ifname, 'encapsulation']) - if tmp: tunnel.update({'encapsulation_changed': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'parameters', 'ip', 'key']) - if tmp: tunnel.update({'key_changed': {}}) - - # We also need to inspect other configured tunnels as there are Kernel - # restrictions where we need to comply. E.g. GRE tunnel key can't be used - # twice, or with multiple GRE tunnels to the same location we must specify - # a GRE key - conf.set_level(base) - tunnel['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - # delete our own instance from this dict - ifname = tunnel['ifname'] - del tunnel['other_tunnels'][ifname] - # if only one tunnel is present on the system, no need to keep this key - if len(tunnel['other_tunnels']) == 0: - del tunnel['other_tunnels'] - - # We must check if our interface is configured to be a DMVPN member - nhrp_base = ['protocols', 'nhrp', 'tunnel'] - conf.set_level(nhrp_base) - nhrp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) - if nhrp: tunnel.update({'nhrp' : list(nhrp.keys())}) - - if 'encapsulation' in tunnel and tunnel['encapsulation'] not in ['erspan', 'ip6erspan']: - del tunnel['parameters']['erspan'] - - return tunnel - -def verify(tunnel): - if 'deleted' in tunnel: - verify_bridge_delete(tunnel) - - if 'nhrp' in tunnel and tunnel['ifname'] in tunnel['nhrp']: - raise ConfigError('Tunnel used for NHRP, it can not be deleted!') - - return None - - verify_tunnel(tunnel) - - if tunnel['encapsulation'] in ['erspan', 'ip6erspan']: - if dict_search('parameters.ip.key', tunnel) == None: - raise ConfigError('ERSPAN requires ip key parameter!') - - # this is a default field - ver = int(tunnel['parameters']['erspan']['version']) - if ver == 1: - if 'hw_id' in tunnel['parameters']['erspan']: - raise ConfigError('ERSPAN version 1 does not support hw-id!') - if 'direction' in tunnel['parameters']['erspan']: - raise ConfigError('ERSPAN version 1 does not support direction!') - elif ver == 2: - if 'idx' in tunnel['parameters']['erspan']: - raise ConfigError('ERSPAN version 2 does not index parameter!') - if 'direction' not in tunnel['parameters']['erspan']: - raise ConfigError('ERSPAN version 2 requires direction to be set!') - - # If tunnel source is any and gre key is not set - interface = tunnel['ifname'] - if tunnel['encapsulation'] in ['gre'] and \ - dict_search('source_address', tunnel) == '0.0.0.0' and \ - dict_search('parameters.ip.key', tunnel) == None: - raise ConfigError(f'"parameters ip key" must be set for {interface} when '\ - 'encapsulation is GRE!') - - gre_encapsulations = ['gre', 'gretap'] - if tunnel['encapsulation'] in gre_encapsulations and 'other_tunnels' in tunnel: - # Check pairs tunnel source-address/encapsulation/key with exists tunnels. - # Prevent the same key for 2 tunnels with same source-address/encap. T2920 - for o_tunnel, o_tunnel_conf in tunnel['other_tunnels'].items(): - # no match on encapsulation - bail out - our_encapsulation = tunnel['encapsulation'] - their_encapsulation = o_tunnel_conf['encapsulation'] - if our_encapsulation in gre_encapsulations and their_encapsulation \ - not in gre_encapsulations: - continue - - our_address = dict_search('source_address', tunnel) - our_key = dict_search('parameters.ip.key', tunnel) - their_address = dict_search('source_address', o_tunnel_conf) - their_key = dict_search('parameters.ip.key', o_tunnel_conf) - if our_key != None: - if their_address == our_address and their_key == our_key: - raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" ' \ - f'is already used for tunnel "{o_tunnel}"!') - else: - our_source_if = dict_search('source_interface', tunnel) - their_source_if = dict_search('source_interface', o_tunnel_conf) - our_remote = dict_search('remote', tunnel) - their_remote = dict_search('remote', o_tunnel_conf) - # If no IP GRE key is defined we can not have more then one GRE tunnel - # bound to any one interface/IP address and the same remote. This will - # result in a OS PermissionError: add tunnel "gre0" failed: File exists - if (their_address == our_address or our_source_if == their_source_if) and \ - our_remote == their_remote: - raise ConfigError(f'Missing required "ip key" parameter when '\ - 'running more then one GRE based tunnel on the '\ - 'same source-interface/source-address') - - # Keys are not allowed with ipip and sit tunnels - if tunnel['encapsulation'] in ['ipip', 'sit']: - if dict_search('parameters.ip.key', tunnel) != None: - raise ConfigError('Keys are not allowed with ipip and sit tunnels!') - - verify_mtu_ipv6(tunnel) - verify_address(tunnel) - verify_vrf(tunnel) - verify_bond_bridge_member(tunnel) - verify_mirror_redirect(tunnel) - - if 'source_interface' in tunnel: - verify_interface_exists(tunnel['source_interface']) - - # TTL != 0 and nopmtudisc are incompatible, parameters and ip use default - # values, thus the keys are always present. - if dict_search('parameters.ip.no_pmtu_discovery', tunnel) != None: - if dict_search('parameters.ip.ttl', tunnel) != '0': - raise ConfigError('Disabled PMTU requires TTL set to "0"!') - if tunnel['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']: - raise ConfigError('Can not disable PMTU discovery for given encapsulation') - - if dict_search('parameters.ip.ignore_df', tunnel) != None: - if tunnel['encapsulation'] not in ['gretap']: - raise ConfigError('Option ignore-df can only be used on GRETAP tunnels!') - - if dict_search('parameters.ip.no_pmtu_discovery', tunnel) == None: - raise ConfigError('Option ignore-df requires path MTU discovery to be disabled!') - - -def generate(tunnel): - return None - -def apply(tunnel): - interface = tunnel['ifname'] - # If a gretap tunnel is already existing we can not "simply" change local or - # remote addresses. This returns "Operation not supported" by the Kernel. - # There is no other solution to destroy and recreate the tunnel. - encap = '' - remote = '' - tmp = get_interface_config(interface) - if tmp: - encap = dict_search('linkinfo.info_kind', tmp) - remote = dict_search('linkinfo.info_data.remote', tmp) - - if ('deleted' in tunnel or 'encapsulation_changed' in tunnel or encap in - ['gretap', 'ip6gretap', 'erspan', 'ip6erspan'] or remote in ['any'] or - 'key_changed' in tunnel): - if interface in interfaces(): - tmp = Interface(interface) - tmp.remove() - if 'deleted' in tunnel: - return None - - tun = TunnelIf(**tunnel) - tun.update(tunnel) - - return None - -if __name__ == '__main__': - try: - c = get_config() - generate(c) - verify(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-virtual-ethernet.py b/src/conf_mode/interfaces-virtual-ethernet.py deleted file mode 100755 index 8efe89c41..000000000 --- a/src/conf_mode/interfaces-virtual-ethernet.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2022 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 . - -from sys import exit - -from netifaces import interfaces -from vyos import ConfigError -from vyos import airbag -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_vrf -from vyos.ifconfig import VethIf - -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at - least the interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'virtual-ethernet'] - ifname, veth = get_interface_dict(conf, base) - - # We need to know all other veth related interfaces as veth requires a 1:1 - # mapping for the peer-names. The Linux kernel automatically creates both - # interfaces, the local one and the peer-name, but VyOS also needs a peer - # interfaces configrued on the CLI so we can assign proper IP addresses etc. - veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - return veth - - -def verify(veth): - if 'deleted' in veth: - verify_bridge_delete(veth) - # Prevent to delete veth interface which used for another "vethX peer-name" - for iface, iface_config in veth['other_interfaces'].items(): - if veth['ifname'] in iface_config['peer_name']: - ifname = veth['ifname'] - raise ConfigError( - f'Cannot delete "{ifname}" used for "interface {iface} peer-name"' - ) - return None - - verify_vrf(veth) - verify_address(veth) - - if 'peer_name' not in veth: - raise ConfigError(f'Remote peer name must be set for "{veth["ifname"]}"!') - - peer_name = veth['peer_name'] - ifname = veth['ifname'] - - if veth['peer_name'] not in veth['other_interfaces']: - raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ - 'is not configured!') - - if veth['other_interfaces'][peer_name]['peer_name'] != ifname: - raise ConfigError( - f'Configuration mismatch between "{ifname}" and "{peer_name}"!') - - if peer_name == ifname: - raise ConfigError( - f'Peer-name "{peer_name}" cannot be the same as interface "{ifname}"!') - - return None - - -def generate(peth): - return None - -def apply(veth): - # Check if the Veth interface already exists - if 'rebuild_required' in veth or 'deleted' in veth: - if veth['ifname'] in interfaces(): - p = VethIf(veth['ifname']) - p.remove() - - if 'deleted' not in veth: - p = VethIf(**veth) - p.update(veth) - - 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/interfaces-vti.py b/src/conf_mode/interfaces-vti.py deleted file mode 100755 index 9871810ae..000000000 --- a/src/conf_mode/interfaces-vti.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 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 . - -from netifaces import interfaces -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_mirror_redirect -from vyos.ifconfig import VTIIf -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'vti'] - _, vti = get_interface_dict(conf, base) - return vti - -def verify(vti): - verify_mirror_redirect(vti) - return None - -def generate(vti): - return None - -def apply(vti): - # Remove macsec interface - if 'deleted' in vti: - VTIIf(**vti).remove() - return None - - tmp = VTIIf(**vti) - tmp.update(vti) - - 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/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py deleted file mode 100755 index 4251e611b..000000000 --- a/src/conf_mode/interfaces-vxlan.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os - -from sys import exit -from netifaces import interfaces - -from vyos.base import Warning -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import leaf_node_changed -from vyos.configdict import is_node_changed -from vyos.configdict import node_changed -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_source_interface -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import Interface -from vyos.ifconfig import VXLANIf -from vyos.template import is_ipv6 -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least - the interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'vxlan'] - ifname, vxlan = get_interface_dict(conf, base) - - # VXLAN interfaces are picky and require recreation if certain parameters - # change. But a VXLAN 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 ['parameters', 'gpe', 'group', 'port', 'remote', - 'source-address', 'source-interface', 'vni']: - if is_node_changed(conf, base + [ifname, cli_option]): - vxlan.update({'rebuild_required': {}}) - break - - # When dealing with VNI filtering we need to know what VNI was actually removed, - # so build up a dict matching the vlan_to_vni structure but with removed values. - tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True) - if tmp: - vxlan.update({'vlan_to_vni_removed': {}}) - for vlan in tmp: - vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) - vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) - - # We need to verify that no other VXLAN tunnel is configured when external - # mode is in use - Linux Kernel limitation - conf.set_level(base) - vxlan['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - # This if-clause is just to be sure - it will always evaluate to true - ifname = vxlan['ifname'] - if ifname in vxlan['other_tunnels']: - del vxlan['other_tunnels'][ifname] - if len(vxlan['other_tunnels']) == 0: - del vxlan['other_tunnels'] - - return vxlan - -def verify(vxlan): - if 'deleted' in vxlan: - verify_bridge_delete(vxlan) - return None - - if int(vxlan['mtu']) < 1500: - Warning('RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU') - - if 'group' in vxlan: - if 'source_interface' not in vxlan: - raise ConfigError('Multicast VXLAN requires an underlaying interface') - verify_source_interface(vxlan) - - if not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan): - raise ConfigError('Group, remote, source-address or source-interface must be configured') - - if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None: - raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!') - - if dict_search('parameters.external', vxlan) != None: - if 'vni' in vxlan: - raise ConfigError('Can not specify both "external" and "VNI"!') - - if 'other_tunnels' in vxlan: - # When multiple VXLAN interfaces are defined and "external" is used, - # all VXLAN interfaces need to have vni-filter enabled! - # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9 - other_vni_filter = False - for tunnel, tunnel_config in vxlan['other_tunnels'].items(): - if dict_search('parameters.vni_filter', tunnel_config) != None: - other_vni_filter = True - break - # eqivalent of the C foo ? 'a' : 'b' statement - vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False - # If either one is enabled, so must be the other. Both can be off and both can be on - if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): - raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\ - 'requires all VXLAN interfaces to have "vni-filter" configured!') - - if not vni_filter and not other_vni_filter: - other_tunnels = ', '.join(vxlan['other_tunnels']) - raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ - f'CLI option is used and "vni-filter" is unset. '\ - f'Additional tunnels: {other_tunnels}') - - if 'gpe' in vxlan and 'external' not in vxlan: - raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ - f'CLI option is used.') - - if 'source_interface' in vxlan: - # VXLAN adds at least an overhead of 50 byte - we need to check the - # underlaying device if our VXLAN package is not going to be fragmented! - vxlan_overhead = 50 - if 'source_address' in vxlan and is_ipv6(vxlan['source_address']): - # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 - # bytes larger than the IPv4 header - assuming no extra options are - # in use. - vxlan_overhead += 20 - - # If source_address is not used - check IPv6 'remote' list - elif 'remote' in vxlan: - if any(is_ipv6(a) for a in vxlan['remote']): - vxlan_overhead += 20 - - lower_mtu = Interface(vxlan['source_interface']).get_mtu() - if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): - raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ - f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') - - # Check for mixed IPv4 and IPv6 addresses - protocol = None - if 'source_address' in vxlan: - if is_ipv6(vxlan['source_address']): - protocol = 'ipv6' - else: - protocol = 'ipv4' - - if 'remote' in vxlan: - error_msg = 'Can not mix both IPv4 and IPv6 for VXLAN underlay' - for remote in vxlan['remote']: - if is_ipv6(remote): - if protocol == 'ipv4': - raise ConfigError(error_msg) - protocol = 'ipv6' - else: - if protocol == 'ipv6': - raise ConfigError(error_msg) - protocol = 'ipv4' - - if 'vlan_to_vni' in vxlan: - if 'is_bridge_member' not in vxlan: - raise ConfigError('VLAN to VNI mapping requires that VXLAN interface '\ - 'is member of a bridge interface!') - - vnis_used = [] - for vif, vif_config in vxlan['vlan_to_vni'].items(): - if 'vni' not in vif_config: - raise ConfigError(f'Must define VNI for VLAN "{vif}"!') - vni = vif_config['vni'] - if vni in vnis_used: - raise ConfigError(f'VNI "{vni}" is already assigned to a different VLAN!') - vnis_used.append(vni) - - if dict_search('parameters.neighbor_suppress', vxlan) != None: - if 'is_bridge_member' not in vxlan: - raise ConfigError('Neighbor suppression requires that VXLAN interface '\ - 'is member of a bridge interface!') - - verify_mtu_ipv6(vxlan) - verify_address(vxlan) - verify_bond_bridge_member(vxlan) - verify_mirror_redirect(vxlan) - - # We use a defaultValue for port, thus it's always safe to use - if vxlan['port'] == '8472': - Warning('Starting from VyOS 1.4, the default port for VXLAN '\ - 'has been changed to 4789. This matches the IANA assigned '\ - 'standard port number!') - - return None - -def generate(vxlan): - return None - -def apply(vxlan): - # Check if the VXLAN interface already exists - if 'rebuild_required' in vxlan or 'delete' in vxlan: - if vxlan['ifname'] in interfaces(): - v = VXLANIf(vxlan['ifname']) - # VXLAN is super picky and the tunnel always needs to be recreated, - # thus we can simply always delete it first. - v.remove() - - if 'deleted' not in vxlan: - # Finally create the new interface - v = VXLANIf(**vxlan) - v.update(vxlan) - - 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/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py deleted file mode 100755 index 79e5d3f44..000000000 --- a/src/conf_mode/interfaces-wireguard.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_vrf -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mtu_ipv6 -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import WireGuardIf -from vyos.utils.kernel import check_kmod -from vyos.utils.network import check_port_availability -from vyos.utils.network import is_wireguard_key_pair -from vyos import ConfigError -from vyos import airbag -airbag.enable() - - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'wireguard'] - ifname, wireguard = get_interface_dict(conf, base) - - # Check if a port was changed - tmp = is_node_changed(conf, base + [ifname, 'port']) - if tmp: wireguard['port_changed'] = {} - - # T4702: If anything on a peer changes we remove the peer first and re-add it - if is_node_changed(conf, base + [ifname, 'peer']): - wireguard.update({'rebuild_required': {}}) - - return wireguard - -def verify(wireguard): - if 'deleted' in wireguard: - verify_bridge_delete(wireguard) - return None - - verify_mtu_ipv6(wireguard) - verify_address(wireguard) - verify_vrf(wireguard) - verify_bond_bridge_member(wireguard) - verify_mirror_redirect(wireguard) - - if 'private_key' not in wireguard: - raise ConfigError('Wireguard private-key not defined') - - if 'peer' not in wireguard: - raise ConfigError('At least one Wireguard peer is required!') - - if 'port' in wireguard and 'port_changed' in wireguard: - listen_port = int(wireguard['port']) - if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: - raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' - 'cannot be used for the interface!') - - # run checks on individual configured WireGuard peer - public_keys = [] - for tmp in wireguard['peer']: - peer = wireguard['peer'][tmp] - - if 'allowed_ips' not in peer: - raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') - - 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!') - - if peer['public_key'] in public_keys: - raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') - - 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"]}"') - - public_keys.append(peer['public_key']) - -def apply(wireguard): - if 'rebuild_required' in wireguard or 'deleted' in wireguard: - wg = WireGuardIf(**wireguard) - # WireGuard only supports peer removal based on the configured public-key, - # by deleting the entire interface this is the shortcut instead of parsing - # out all peers and removing them one by one. - # - # Peer reconfiguration will always come with a short downtime while the - # WireGuard interface is recreated (see below) - wg.remove() - - # Create the new interface if required - if 'deleted' not in wireguard: - wg = WireGuardIf(**wireguard) - wg.update(wireguard) - - return None - -if __name__ == '__main__': - try: - check_kmod('wireguard') - c = get_config() - verify(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py deleted file mode 100755 index 02b4a2500..000000000 --- a/src/conf_mode/interfaces-wireless.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 . - -import os - -from sys import exit -from re import findall -from netaddr import EUI, mac_unix_expanded - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import dict_merge -from vyos.configverify import verify_address -from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_vlan_config -from vyos.configverify import verify_vrf -from vyos.configverify import verify_bond_bridge_member -from vyos.ifconfig import WiFiIf -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -# XXX: wpa_supplicant works on the source interface -wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' -hostapd_conf = '/run/hostapd/{ifname}.conf' -hostapd_accept_station_conf = '/run/hostapd/{ifname}_station_accept.conf' -hostapd_deny_station_conf = '/run/hostapd/{ifname}_station_deny.conf' - -def find_other_stations(conf, base, ifname): - """ - Only one wireless interface per phy can be in station mode - - find all interfaces attached to a phy which run in station mode - """ - old_level = conf.get_level() - conf.set_level(base) - dict = {} - for phy in os.listdir('/sys/class/ieee80211'): - list = [] - for interface in conf.list_nodes([]): - if interface == ifname: - continue - # the following node is mandatory - if conf.exists([interface, 'physical-device', phy]): - tmp = conf.return_value([interface, 'type']) - if tmp == 'station': - list.append(interface) - if list: - dict.update({phy: list}) - conf.set_level(old_level) - return dict - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'wireless'] - - ifname, wifi = get_interface_dict(conf, base) - - if 'deleted' not in wifi: - # then get_interface_dict provides default keys - if wifi.from_defaults(['security', 'wep']): # if not set by user - del wifi['security']['wep'] - if wifi.from_defaults(['security', 'wpa']): # if not set by user - del wifi['security']['wpa'] - - if dict_search('security.wpa', wifi) != None: - wpa_cipher = wifi['security']['wpa'].get('cipher') - wpa_mode = wifi['security']['wpa'].get('mode') - if not wpa_cipher: - tmp = None - if wpa_mode == 'wpa': - tmp = {'security': {'wpa': {'cipher' : ['TKIP', 'CCMP']}}} - elif wpa_mode == 'wpa2': - tmp = {'security': {'wpa': {'cipher' : ['CCMP']}}} - elif wpa_mode == 'both': - tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'TKIP']}}} - - if tmp: wifi = dict_merge(tmp, wifi) - - # Only one wireless interface per phy can be in station mode - tmp = find_other_stations(conf, base, wifi['ifname']) - if tmp: wifi['station_interfaces'] = tmp - - # used in hostapt.conf.j2 - wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi) - wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi) - - return wifi - -def verify(wifi): - if 'deleted' in wifi: - verify_bridge_delete(wifi) - return None - - if 'physical_device' not in wifi: - raise ConfigError('You must specify a physical-device "phy"') - - if 'type' not in wifi: - raise ConfigError('You must specify a WiFi mode') - - if 'ssid' not in wifi and wifi['type'] != 'monitor': - raise ConfigError('SSID must be configured unless type is set to "monitor"!') - - if wifi['type'] == 'access-point': - if 'country_code' not in wifi: - raise ConfigError('Wireless country-code is mandatory') - - if 'channel' not in wifi: - raise ConfigError('Wireless channel must be configured!') - - if 'security' in wifi: - if {'wep', 'wpa'} <= set(wifi.get('security', {})): - raise ConfigError('Must either use WEP or WPA security!') - - if 'wep' in wifi['security']: - if 'key' in wifi['security']['wep'] and len(wifi['security']['wep']) > 4: - raise ConfigError('No more then 4 WEP keys configurable') - elif 'key' not in wifi['security']['wep']: - raise ConfigError('Security WEP configured - missing WEP keys!') - - elif 'wpa' in wifi['security']: - wpa = wifi['security']['wpa'] - if not any(i in ['passphrase', 'radius'] for i in wpa): - raise ConfigError('Misssing WPA key or RADIUS server') - - if 'radius' in wpa: - if 'server' in wpa['radius']: - for server in wpa['radius']['server']: - if 'key' not in wpa['radius']['server'][server]: - raise ConfigError(f'Misssing RADIUS shared secret key for server: {server}') - - if 'capabilities' in wifi: - capabilities = wifi['capabilities'] - if 'vht' in capabilities: - if 'ht' not in capabilities: - raise ConfigError('Specify HT flags if you want to use VHT!') - - if {'beamform', 'antenna_count'} <= set(capabilities.get('vht', {})): - if capabilities['vht']['antenna_count'] == '1': - raise ConfigError('Cannot use beam forming with just one antenna!') - - if capabilities['vht']['beamform'] == 'single-user-beamformer': - if int(capabilities['vht']['antenna_count']) < 3: - # Nasty Gotcha: see https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf lines 692-705 - raise ConfigError('Single-user beam former requires at least 3 antennas!') - - if 'station_interfaces' in wifi and wifi['type'] == 'station': - phy = wifi['physical_device'] - if phy in wifi['station_interfaces']: - if len(wifi['station_interfaces'][phy]) > 0: - raise ConfigError('Only one station per wireless physical interface possible!') - - verify_address(wifi) - verify_vrf(wifi) - verify_bond_bridge_member(wifi) - verify_mirror_redirect(wifi) - - # use common function to verify VLAN configuration - verify_vlan_config(wifi) - - return None - -def generate(wifi): - interface = wifi['ifname'] - - # always stop hostapd service first before reconfiguring it - call(f'systemctl stop hostapd@{interface}.service') - # always stop wpa_supplicant service first before reconfiguring it - call(f'systemctl stop wpa_supplicant@{interface}.service') - - # Delete config files if interface is removed - if 'deleted' in wifi: - if os.path.isfile(hostapd_conf.format(**wifi)): - os.unlink(hostapd_conf.format(**wifi)) - if os.path.isfile(hostapd_accept_station_conf.format(**wifi)): - os.unlink(hostapd_accept_station_conf.format(**wifi)) - if os.path.isfile(hostapd_deny_station_conf.format(**wifi)): - os.unlink(hostapd_deny_station_conf.format(**wifi)) - if os.path.isfile(wpa_suppl_conf.format(**wifi)): - os.unlink(wpa_suppl_conf.format(**wifi)) - - return None - - if 'mac' not in wifi: - # http://wiki.stocksy.co.uk/wiki/Multiple_SSIDs_with_hostapd - # generate locally administered MAC address from used phy interface - with open('/sys/class/ieee80211/{physical_device}/addresses'.format(**wifi), 'r') as f: - # some PHYs tend to have multiple interfaces and thus supply multiple MAC - # addresses - we only need the first one for our calculation - tmp = f.readline().rstrip() - tmp = EUI(tmp).value - # mask last nibble from the MAC address - tmp &= 0xfffffffffff0 - # set locally administered bit in MAC address - tmp |= 0x020000000000 - # we now need to add an offset to our MAC address indicating this - # subinterfaces index - tmp += int(findall(r'\d+', interface)[0]) - - # convert integer to "real" MAC address representation - mac = EUI(hex(tmp).split('x')[-1]) - # change dialect to use : as delimiter instead of - - mac.dialect = mac_unix_expanded - wifi['mac'] = str(mac) - - # XXX: Jinja2 can not operate on a dictionary key when it starts of with a number - if '40mhz_incapable' in (dict_search('capabilities.ht', wifi) or []): - wifi['capabilities']['ht']['fourtymhz_incapable'] = wifi['capabilities']['ht']['40mhz_incapable'] - del wifi['capabilities']['ht']['40mhz_incapable'] - - # render appropriate new config files depending on access-point or station mode - if wifi['type'] == 'access-point': - render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.j2', wifi) - render(hostapd_accept_station_conf.format(**wifi), 'wifi/hostapd_accept_station.conf.j2', wifi) - render(hostapd_deny_station_conf.format(**wifi), 'wifi/hostapd_deny_station.conf.j2', wifi) - - elif wifi['type'] == 'station': - render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.j2', wifi) - - return None - -def apply(wifi): - interface = wifi['ifname'] - if 'deleted' in wifi: - WiFiIf(interface).remove() - else: - # Finally create the new interface - w = WiFiIf(**wifi) - w.update(wifi) - - # Enable/Disable interface - interface is always placed in - # administrative down state in WiFiIf class - if 'disable' not in wifi: - # Physical interface is now configured. Proceed by starting hostapd or - # wpa_supplicant daemon. When type is monitor we can just skip this. - if wifi['type'] == 'access-point': - call(f'systemctl start hostapd@{interface}.service') - - elif wifi['type'] == 'station': - call(f'systemctl start wpa_supplicant@{interface}.service') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py deleted file mode 100755 index 2515dc838..000000000 --- a/src/conf_mode/interfaces-wwan.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2022 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 . - -import os - -from sys import exit -from time import sleep - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed -from vyos.configverify import verify_authentication -from vyos.configverify import verify_interface_exists -from vyos.configverify import verify_mirror_redirect -from vyos.configverify import verify_vrf -from vyos.ifconfig import WWANIf -from vyos.utils.dict import dict_search -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import DEVNULL -from vyos.utils.process import is_systemd_service_active -from vyos.utils.file import write_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -service_name = 'ModemManager.service' -cron_script = '/etc/cron.d/vyos-wwan' - -def get_config(config=None): - """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the - interface name will be added or a deleted flag - """ - if config: - conf = config - else: - conf = Config() - base = ['interfaces', 'wwan'] - ifname, wwan = get_interface_dict(conf, base) - - # We should only terminate the WWAN session if critical parameters change. - # All parameters that can be changed on-the-fly (like interface description) - # should not lead to a reconnect! - tmp = is_node_changed(conf, base + [ifname, 'address']) - if tmp: wwan.update({'shutdown_required': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'apn']) - if tmp: wwan.update({'shutdown_required': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'disable']) - if tmp: wwan.update({'shutdown_required': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'vrf']) - if tmp: wwan.update({'shutdown_required': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'authentication']) - if tmp: wwan.update({'shutdown_required': {}}) - - tmp = is_node_changed(conf, base + [ifname, 'ipv6', 'address', 'autoconf']) - if tmp: wwan.update({'shutdown_required': {}}) - - # We need to know the amount of other WWAN interfaces as ModemManager needs - # to be started or stopped. - wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) - - # This if-clause is just to be sure - it will always evaluate to true - if ifname in wwan['other_interfaces']: - del wwan['other_interfaces'][ifname] - if len(wwan['other_interfaces']) == 0: - del wwan['other_interfaces'] - - return wwan - -def verify(wwan): - if 'deleted' in wwan: - return None - - ifname = wwan['ifname'] - if not 'apn' in wwan: - raise ConfigError(f'No APN configured for "{ifname}"!') - - verify_interface_exists(ifname) - verify_authentication(wwan) - verify_vrf(wwan) - verify_mirror_redirect(wwan) - - return None - -def generate(wwan): - if 'deleted' in wwan: - # We are the last WWAN interface - there are no other ones remaining - # thus the cronjob needs to go away, too - if 'other_interfaces' not in wwan: - if os.path.exists(cron_script): - os.unlink(cron_script) - return None - - # Install cron triggered helper script to re-dial WWAN interfaces on - # disconnect - e.g. happens during RF signal loss. The script watches every - # WWAN interface - so there is only one instance. - if not os.path.exists(cron_script): - write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n') - - return None - -def apply(wwan): - # ModemManager is required to dial WWAN connections - one instance is - # required to serve all modems. Activate ModemManager on first invocation - # of any WWAN interface. - if not is_systemd_service_active(service_name): - cmd(f'systemctl start {service_name}') - - counter = 100 - # Wait until a modem is detected and then we can continue - while counter > 0: - counter -= 1 - tmp = cmd('mmcli -L') - if tmp != 'No modems were found': - break - sleep(0.250) - - if 'shutdown_required' in wwan: - # we only need the modem number. wwan0 -> 0, wwan1 -> 1 - modem = wwan['ifname'].lstrip('wwan') - base_cmd = f'mmcli --modem {modem}' - # Number of bearers is limited - always disconnect first - cmd(f'{base_cmd} --simple-disconnect') - - w = WWANIf(wwan['ifname']) - if 'deleted' in wwan or 'disable' in wwan: - w.remove() - - # We are the last WWAN interface - there are no other WWAN interfaces - # remaining, thus we can stop ModemManager and free resources. - if 'other_interfaces' not in wwan: - cmd(f'systemctl stop {service_name}') - # Clean CRON helper script which is used for to re-connect when - # RF signal is lost - if os.path.exists(cron_script): - os.unlink(cron_script) - - return None - - if 'shutdown_required' in wwan: - ip_type = 'ipv4' - slaac = dict_search('ipv6.address.autoconf', wwan) != None - if 'address' in wwan: - if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): - ip_type = 'ipv4v6' - elif 'dhcpv6' in wwan['address'] or slaac: - ip_type = 'ipv6' - elif 'dhcp' in wwan['address']: - ip_type = 'ipv4' - - options = f'ip-type={ip_type},apn=' + wwan['apn'] - if 'authentication' in wwan: - options += ',user={username},password={password}'.format(**wwan['authentication']) - - command = f'{base_cmd} --simple-connect="{options}"' - call(command, stdout=DEVNULL) - - w.update(wwan) - 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/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py new file mode 100755 index 000000000..8184d8415 --- /dev/null +++ b/src/conf_mode/interfaces_bonding.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os + +from sys import exit +from netifaces import interfaces +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configdict import leaf_node_changed +from vyos.configdict import is_member +from vyos.configdict import is_source_interface +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +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.dict import dict_search +from vyos.utils.dict import dict_to_paths_values +from vyos.configdict import has_address_configured +from vyos.configdict import has_vrf_configured +from vyos.configdep import set_dependents, call_dependents +from vyos import ConfigError +from vyos import frr +from vyos import airbag +airbag.enable() + +def get_bond_mode(mode): + if mode == 'round-robin': + return 'balance-rr' + elif mode == 'active-backup': + return 'active-backup' + elif mode == 'xor-hash': + return 'balance-xor' + elif mode == 'broadcast': + return 'broadcast' + elif mode == '802.3ad': + return '802.3ad' + elif mode == 'transmit-load-balance': + return 'balance-tlb' + elif mode == 'adaptive-load-balance': + return 'balance-alb' + else: + raise ConfigError(f'invalid bond mode "{mode}"') + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'bonding'] + ifname, bond = get_interface_dict(conf, base) + + # To make our own life easier transfor the list of member interfaces + # into a dictionary - we will use this to add additional information + # later on for each member + if 'member' in bond and 'interface' in bond['member']: + # convert list of member interfaces to a dictionary + bond['member']['interface'] = {k: {} for k in bond['member']['interface']} + + if 'mode' in bond: + bond['mode'] = get_bond_mode(bond['mode']) + + tmp = is_node_changed(conf, base + [ifname, 'mode']) + if tmp: bond['shutdown_required'] = {} + + tmp = is_node_changed(conf, base + [ifname, 'lacp-rate']) + if tmp: bond['shutdown_required'] = {} + + # determine which members have been removed + interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) + # Reset config level to interfaces + old_level = conf.get_level() + conf.set_level(['interfaces']) + + if interfaces_removed: + bond['shutdown_required'] = {} + if 'member' not in bond: + bond['member'] = {} + + tmp = {} + for interface in interfaces_removed: + # if member is deleted from bond, add dependencies to call + # ethernet commit again in apply function + # to apply options under ethernet section + set_dependents('ethernet', conf, interface) + section = Section.section(interface) # this will be 'ethernet' for 'eth0' + if conf.exists([section, interface, 'disable']): + tmp[interface] = {'disable': ''} + else: + tmp[interface] = {} + + # also present the interfaces to be removed from the bond as dictionary + bond['member']['interface_remove'] = tmp + + # Restore existing config level + conf.set_level(old_level) + + if dict_search('member.interface', bond): + for interface, interface_config in bond['member']['interface'].items(): + + interface_ethernet_config = conf.get_config_dict( + ['interfaces', 'ethernet', interface], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=False, + with_recursive_defaults=False) + + interface_config['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'] = {} + + # 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'] = '' + + 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 + + # 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 + + # 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 + + # bond members must not have an assigned address + tmp = has_address_configured(conf, interface) + if tmp: interface_config['has_address'] = {} + + # bond members must not have a VRF attached + tmp = has_vrf_configured(conf, interface) + if tmp: interface_config['has_vrf'] = {} + return bond + + +def verify(bond): + if 'deleted' in bond: + verify_bridge_delete(bond) + return None + + if 'arp_monitor' in bond: + if 'target' in bond['arp_monitor'] and len(bond['arp_monitor']['target']) > 16: + raise ConfigError('The maximum number of arp-monitor targets is 16') + + if 'interval' in bond['arp_monitor'] and int(bond['arp_monitor']['interval']) > 0: + if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']: + raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \ + 'transmit-load-balance or adaptive-load-balance') + + if 'primary' in bond: + if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: + raise ConfigError('Option primary - mode dependency failed, not' + 'supported in mode {mode}!'.format(**bond)) + + verify_mtu_ipv6(bond) + verify_address(bond) + verify_dhcpv6(bond) + verify_vrf(bond) + verify_mirror_redirect(bond) + + # use common function to verify VLAN configuration + verify_vlan_config(bond) + + bond_name = bond['ifname'] + if dict_search('member.interface', bond): + for interface, interface_config in bond['member']['interface'].items(): + error_msg = f'Can not add interface "{interface}" to bond, ' + + if interface == 'lo': + raise ConfigError('Loopback interface "lo" can not be added to a bond') + + if interface not in interfaces(): + raise ConfigError(error_msg + 'it does not exist!') + + if 'is_bridge_member' in interface_config: + tmp = next(iter(interface_config['is_bridge_member'])) + raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') + + if 'is_bond_member' in interface_config: + tmp = next(iter(interface_config['is_bond_member'])) + raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') + + if 'is_source_interface' in interface_config: + tmp = interface_config['is_source_interface'] + raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!') + + if 'has_address' in interface_config: + raise ConfigError(error_msg + 'it has an address assigned!') + + if 'has_vrf' in interface_config: + raise ConfigError(error_msg + 'it has a VRF assigned!') + + if 'new_added' in interface_config and 'config_paths' in interface_config: + for option_path, option_value in interface_config['config_paths'].items(): + if option_path in EthernetIf.get_bond_member_allowed_options() : + continue + if option_path in BondIf.get_inherit_bond_options(): + continue + raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!') + + if 'primary' in bond: + if bond['primary'] not in bond['member']['interface']: + raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') + + if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']: + raise ConfigError('primary interface only works for mode active-backup, ' \ + 'transmit-load-balance or adaptive-load-balance') + + 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) + return None + +def apply(bond): + ifname = bond['ifname'] + b = BondIf(ifname) + if 'deleted' in bond: + # delete interface + b.remove() + else: + b.update(bond) + + if dict_search('member.interface_remove', bond): + try: + call_dependents() + except ConfigError: + 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__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py new file mode 100755 index 000000000..29991e2da --- /dev/null +++ b/src/conf_mode/interfaces_bridge.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import node_changed +from vyos.configdict import is_member +from vyos.configdict import is_source_interface +from vyos.configdict import has_vlan_subinterface_configured +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_vrf +from vyos.ifconfig import BridgeIf +from vyos.configdict import has_address_configured +from vyos.configdict import has_vrf_configured +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos.utils.dict import dict_search +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'bridge'] + ifname, bridge = get_interface_dict(conf, base) + + # determine which members have been removed + tmp = node_changed(conf, base + [ifname, 'member', 'interface']) + if tmp: + if 'member' in bridge: + bridge['member'].update({'interface_remove' : tmp }) + else: + bridge.update({'member' : {'interface_remove' : tmp }}) + + if dict_search('member.interface', bridge) is not 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}) + + # Check if member interface is already member of a bond + tmp = is_member(conf, interface, 'bonding') + if tmp: bridge['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: bridge['member']['interface'][interface].update({'is_source_interface' : tmp}) + + # Bridge members must not have an assigned address + tmp = has_address_configured(conf, interface) + if tmp: bridge['member']['interface'][interface].update({'has_address' : ''}) + + # Bridge members must not have a VRF attached + tmp = has_vrf_configured(conf, interface) + if tmp: bridge['member']['interface'][interface].update({'has_vrf' : ''}) + + # VLAN-aware bridge members must not have VLAN interface configuration + tmp = has_vlan_subinterface_configured(conf,interface) + if 'enable_vlan' in bridge and tmp: + bridge['member']['interface'][interface].update({'has_vlan' : ''}) + + # When using VXLAN member interfaces that are configured for Single + # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to re-create + # VLAN to VNI mappings if required + if interface.startswith('vxlan'): + set_dependents('vxlan', conf, interface) + + # delete empty dictionary keys - no need to run code paths if nothing is there to do + if 'member' in bridge: + if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: + del bridge['member']['interface'] + + if len(bridge['member']) == 0: + del bridge['member'] + + return bridge + +def verify(bridge): + if 'deleted' in bridge: + return None + + verify_dhcpv6(bridge) + verify_vrf(bridge) + verify_mirror_redirect(bridge) + + ifname = bridge['ifname'] + + if dict_search('member.interface', bridge): + for interface, interface_config in bridge['member']['interface'].items(): + error_msg = f'Can not add interface "{interface}" to bridge, ' + + if interface == 'lo': + raise ConfigError('Loopback interface "lo" can not be added to a bridge') + + if 'is_bridge_member' in interface_config: + tmp = next(iter(interface_config['is_bridge_member'])) + raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!') + + if 'is_bond_member' in interface_config: + tmp = next(iter(interface_config['is_bond_member'])) + raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!') + + if 'is_source_interface' in interface_config: + tmp = interface_config['is_source_interface'] + raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!') + + if 'has_address' in interface_config: + raise ConfigError(error_msg + 'it has an address assigned!') + + if 'has_vrf' in interface_config: + raise ConfigError(error_msg + 'it has a VRF assigned!') + + if 'enable_vlan' in bridge: + if 'has_vlan' in interface_config: + raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') + + if 'wlan' in interface: + raise ConfigError(error_msg + 'VLAN aware cannot be set!') + else: + for option in ['allowed_vlan', 'native_vlan']: + if option in interface_config: + raise ConfigError('Can not use VLAN options on non VLAN aware bridge') + + if 'enable_vlan' in bridge: + if dict_search('vif.1', bridge): + raise ConfigError(f'VLAN 1 sub interface cannot be set for VLAN aware bridge {ifname}, and VLAN 1 is always the parent interface') + else: + if dict_search('vif', bridge): + raise ConfigError(f'You must first activate "enable-vlan" of {ifname} bridge to use "vif"') + + return None + +def generate(bridge): + return None + +def apply(bridge): + br = BridgeIf(bridge['ifname']) + if 'deleted' in bridge: + # delete interface + br.remove() + else: + br.update(bridge) + + for interface in dict_search('member.interface', bridge) or []: + if interface.startswith('vxlan'): + try: + call_dependents() + except ConfigError: + raise ConfigError('Error in updating VXLAN interface after changing bridge!') + + 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/interfaces_dummy.py b/src/conf_mode/interfaces_dummy.py new file mode 100755 index 000000000..db768b94d --- /dev/null +++ b/src/conf_mode/interfaces_dummy.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import DummyIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'dummy'] + _, dummy = get_interface_dict(conf, base) + return dummy + +def verify(dummy): + if 'deleted' in dummy: + verify_bridge_delete(dummy) + return None + + verify_vrf(dummy) + verify_address(dummy) + verify_mirror_redirect(dummy) + + return None + +def generate(dummy): + return None + +def apply(dummy): + d = DummyIf(**dummy) + + # Remove dummy interface + if 'deleted' in dummy: + d.remove() + else: + d.update(dummy) + + 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/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py new file mode 100755 index 000000000..7374a29f7 --- /dev/null +++ b/src/conf_mode/interfaces_ethernet.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2021 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 . + +import os +import pprint + +from glob import glob +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_dhcpv6 +from vyos.configverify import verify_eapol +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_mtu +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member +from vyos.ethtool import Ethtool +from vyos.ifconfig import EthernetIf +from vyos.ifconfig import BondIf +from vyos.pki import find_chain +from vyos.pki import encode_certificate +from vyos.pki import load_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.utils.process import call +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.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +cfg_dir = '/run/wpa_supplicant' +wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' + +def update_bond_options(conf: Config, eth_conf: dict) -> list: + """ + Return list of blocked options if interface is a bond member + :param conf: Config object + :type conf: Config + :param eth_conf: Ethernet config dictionary + :type eth_conf: dict + :return: List of blocked options + :rtype: list + """ + blocked_list = [] + bond_name = list(eth_conf['is_bond_member'].keys())[0] + config_without_defaults = conf.get_config_dict( + ['interfaces', 'ethernet', eth_conf['ifname']], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=False, + with_recursive_defaults=False) + config_with_defaults = conf.get_config_dict( + ['interfaces', 'ethernet', eth_conf['ifname']], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True) + bond_config_with_defaults = conf.get_config_dict( + ['interfaces', 'bonding', bond_name], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True) + eth_dict_paths = dict_to_paths_values(config_without_defaults) + eth_path_base = ['interfaces', 'ethernet', eth_conf['ifname']] + + #if option is configured under ethernet section + for option_path, option_value in eth_dict_paths.items(): + bond_option_value = dict_search(option_path, bond_config_with_defaults) + + #If option is allowed for changing then continue + if option_path in EthernetIf.get_bond_member_allowed_options(): + continue + # if option is inherited from bond then set valued from bond interface + if option_path in BondIf.get_inherit_bond_options(): + # If option equals to bond option then do nothing + if option_value == bond_option_value: + continue + else: + # if ethernet has option and bond interface has + # then copy it from bond + if bond_option_value is not None: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}" to "{option_value}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member.' \ + f' Option is inherited from bond "{bond_name}"') + dict_set(option_path, bond_option_value, eth_conf) + continue + # if ethernet has option and bond interface does not have + # then delete it form dict and do not apply it + else: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member.' \ + f' Option is inherited from bond "{bond_name}"') + dict_delete(option_path, eth_conf) + blocked_list.append(option_path) + + # if inherited option is not configured under ethernet section but configured under bond section + for option_path in BondIf.get_inherit_bond_options(): + bond_option_value = dict_search(option_path, bond_config_with_defaults) + if bond_option_value is not None: + if option_path not in eth_dict_paths: + if is_node_changed(conf, eth_path_base + option_path.split('.')): + Warning( + f'Cannot apply "{option_path.replace(".", " ")}" to "{dict_search(option_path, config_with_defaults)}".' \ + f' Interface "{eth_conf["ifname"]}" is a bond member. ' \ + f'Option is inherited from bond "{bond_name}"') + dict_set(option_path, bond_option_value, eth_conf) + eth_conf['bond_blocked_changes'] = blocked_list + return None + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + + # This must be called prior to get_interface_dict(), as this function will + # alter the config level (config.set_level()) + pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + base = ['interfaces', 'ethernet'] + ifname, ethernet = get_interface_dict(conf, base) + if 'is_bond_member' in ethernet: + update_bond_options(conf, ethernet) + + if 'deleted' not in ethernet: + if pki: ethernet['pki'] = pki + + tmp = is_node_changed(conf, base + [ifname, 'speed']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'duplex']) + if tmp: ethernet.update({'speed_duplex_changed': {}}) + + return ethernet + + + +def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): + """ + Verify speed and duplex + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or + (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): + raise ConfigError( + 'Speed/Duplex missmatch. Must be both auto or manually configured') + + if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': + # We need to verify if the requested speed and duplex setting is + # supported by the underlaying NIC. + speed = ethernet['speed'] + duplex = ethernet['duplex'] + if not ethtool.check_speed_duplex(speed, duplex): + raise ConfigError( + f'Adapter does not support changing speed ' \ + f'and duplex settings to: {speed}/{duplex}!') + + +def verify_flow_control(ethernet: dict, ethtool: Ethtool): + """ + Verify flow control + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'disable_flow_control' in ethernet: + if not ethtool.check_flow_control(): + raise ConfigError( + 'Adapter does not support changing flow-control settings!') + + +def verify_ring_buffer(ethernet: dict, ethtool: Ethtool): + """ + Verify ring buffer + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'ring_buffer' in ethernet: + max_rx = ethtool.get_ring_buffer_max('rx') + if not max_rx: + raise ConfigError( + 'Driver does not support RX ring-buffer configuration!') + + max_tx = ethtool.get_ring_buffer_max('tx') + if not max_tx: + raise ConfigError( + 'Driver does not support TX ring-buffer configuration!') + + rx = dict_search('ring_buffer.rx', ethernet) + if rx and int(rx) > int(max_rx): + raise ConfigError(f'Driver only supports a maximum RX ring-buffer ' \ + f'size of "{max_rx}" bytes!') + + tx = dict_search('ring_buffer.tx', ethernet) + if tx and int(tx) > int(max_tx): + raise ConfigError(f'Driver only supports a maximum TX ring-buffer ' \ + f'size of "{max_tx}" bytes!') + + +def verify_offload(ethernet: dict, ethtool: Ethtool): + """ + Verify offloading capabilities + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if dict_search('offload.rps', ethernet) != None: + if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'): + raise ConfigError('Interface does not suport RPS!') + driver = ethtool.get_driver_name() + # T3342 - Xen driver requires special treatment + if driver == 'vif': + if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None: + raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ + 'for MTU size larger then 1500 bytes') + + +def verify_allowedbond_changes(ethernet: dict): + """ + Verify changed options if interface is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + if 'bond_blocked_changes' in ethernet: + for option in ethernet['bond_blocked_changes']: + raise ConfigError(f'Cannot configure "{option.replace(".", " ")}"' \ + f' on interface "{ethernet["ifname"]}".' \ + f' Interface is a bond member') + + +def verify(ethernet): + if 'deleted' in ethernet: + return None + if 'is_bond_member' in ethernet: + verify_bond_member(ethernet) + else: + verify_ethernet(ethernet) + + +def verify_bond_member(ethernet): + """ + Verification function for ethernet interface which is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + ifname = ethernet['ifname'] + verify_interface_exists(ifname) + verify_eapol(ethernet) + verify_mirror_redirect(ethernet) + ethtool = Ethtool(ifname) + verify_speed_duplex(ethernet, ethtool) + verify_flow_control(ethernet, ethtool) + verify_ring_buffer(ethernet, ethtool) + verify_offload(ethernet, ethtool) + verify_allowedbond_changes(ethernet) + +def verify_ethernet(ethernet): + """ + Verification function for simple ethernet interface + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ + ifname = ethernet['ifname'] + verify_interface_exists(ifname) + verify_mtu(ethernet) + verify_mtu_ipv6(ethernet) + verify_dhcpv6(ethernet) + verify_address(ethernet) + verify_vrf(ethernet) + verify_bond_bridge_member(ethernet) + verify_eapol(ethernet) + verify_mirror_redirect(ethernet) + ethtool = Ethtool(ifname) + # No need to check speed and duplex keys as both have default values. + verify_speed_duplex(ethernet, ethtool) + verify_flow_control(ethernet, ethtool) + verify_ring_buffer(ethernet, ethtool) + verify_offload(ethernet, ethtool) + # use common function to verify VLAN configuration + verify_vlan_config(ethernet) + return None + + +def generate(ethernet): + # render real configuration file once + wpa_supplicant_conf = wpa_suppl_conf.format(**ethernet) + + if 'deleted' in ethernet: + # delete configuration on interface removal + if os.path.isfile(wpa_supplicant_conf): + os.unlink(wpa_supplicant_conf) + return None + + if 'eapol' in ethernet: + ifname = ethernet['ifname'] + + render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', ethernet) + + cert_file_path = os.path.join(cfg_dir, f'{ifname}_cert.pem') + cert_key_path = os.path.join(cfg_dir, f'{ifname}_cert.key') + + cert_name = ethernet['eapol']['certificate'] + pki_cert = ethernet['pki']['certificate'][cert_name] + + loaded_pki_cert = load_certificate(pki_cert['certificate']) + loaded_ca_certs = {load_certificate(c['certificate']) + for c in ethernet['pki']['ca'].values()} if 'ca' in ethernet['pki'] else {} + + cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) + + write_file(cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain)) + write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) + + if 'ca_certificate' in ethernet['eapol']: + ca_cert_file_path = os.path.join(cfg_dir, f'{ifname}_ca.pem') + ca_chains = [] + + for ca_cert_name in ethernet['eapol']['ca_certificate']: + pki_ca_cert = ethernet['pki']['ca'][ca_cert_name] + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + ca_chains.append( + '\n'.join(encode_certificate(c) for c in ca_full_chain)) + + write_file(ca_cert_file_path, '\n'.join(ca_chains)) + + return None + +def apply(ethernet): + ifname = ethernet['ifname'] + # take care about EAPoL supplicant daemon + eapol_action='stop' + + e = EthernetIf(ifname) + if 'deleted' in ethernet: + # delete interface + e.remove() + else: + e.update(ethernet) + if 'eapol' in ethernet: + eapol_action='reload-or-restart' + + call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') + +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/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py new file mode 100755 index 000000000..f6694ddde --- /dev/null +++ b/src/conf_mode/interfaces_geneve.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2022 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 . + +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import GeneveIf +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'geneve'] + ifname, geneve = get_interface_dict(conf, base) + + # 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']: + if is_node_changed(conf, base + [ifname, cli_option]): + geneve.update({'rebuild_required': {}}) + + return geneve + +def verify(geneve): + if 'deleted' in geneve: + verify_bridge_delete(geneve) + return None + + verify_mtu_ipv6(geneve) + verify_address(geneve) + verify_bond_bridge_member(geneve) + verify_mirror_redirect(geneve) + + if 'remote' not in geneve: + raise ConfigError('Remote side must be configured') + + if 'vni' not in geneve: + raise ConfigError('VNI must be configured') + + return None + + +def generate(geneve): + return None + +def apply(geneve): + # Check if GENEVE interface already exists + if 'rebuild_required' in geneve or 'delete' in geneve: + if geneve['ifname'] in interfaces(): + g = GeneveIf(geneve['ifname']) + # GENEVE is super picky and the tunnel always needs to be recreated, + # thus we can simply always delete it first. + g.remove() + + if 'deleted' not in geneve: + # Finally create the new interface + g = GeneveIf(**geneve) + g.update(geneve) + + 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/interfaces_input.py b/src/conf_mode/interfaces_input.py new file mode 100755 index 000000000..ad248843d --- /dev/null +++ b/src/conf_mode/interfaces_input.py @@ -0,0 +1,70 @@ +#!/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 . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import InputIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'input'] + _, ifb = get_interface_dict(conf, base) + + return ifb + +def verify(ifb): + if 'deleted' in ifb: + return None + + verify_mirror_redirect(ifb) + return None + +def generate(ifb): + return None + +def apply(ifb): + d = InputIf(ifb['ifname']) + + # Remove input interface + if 'deleted' in ifb: + d.remove() + else: + d.update(ifb) + + 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/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py new file mode 100755 index 000000000..e1db3206e --- /dev/null +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 . + +import os + +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import L2TPv3If +from vyos.utils.kernel import check_kmod +from vyos.utils.network import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6'] + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'l2tpv3'] + ifname, l2tpv3 = get_interface_dict(conf, base) + + # To delete an l2tpv3 interface we need the current tunnel and session-id + if 'deleted' in l2tpv3: + tmp = leaf_node_changed(conf, base + [ifname, 'tunnel-id']) + # leaf_node_changed() returns a list + l2tpv3.update({'tunnel_id': tmp[0]}) + + tmp = leaf_node_changed(conf, base + [ifname, 'session-id']) + l2tpv3.update({'session_id': tmp[0]}) + + return l2tpv3 + +def verify(l2tpv3): + if 'deleted' in l2tpv3: + verify_bridge_delete(l2tpv3) + return None + + interface = l2tpv3['ifname'] + + for key in ['source_address', 'remote', 'tunnel_id', 'peer_tunnel_id', + 'session_id', 'peer_session_id']: + if key not in l2tpv3: + tmp = key.replace('_', '-') + raise ConfigError(f'Missing mandatory L2TPv3 option: "{tmp}"!') + + if not is_addr_assigned(l2tpv3['source_address']): + raise ConfigError('L2TPv3 source-address address "{source_address}" ' + 'not configured on any interface!'.format(**l2tpv3)) + + verify_mtu_ipv6(l2tpv3) + verify_address(l2tpv3) + verify_bond_bridge_member(l2tpv3) + verify_mirror_redirect(l2tpv3) + return None + +def generate(l2tpv3): + return None + +def apply(l2tpv3): + # Check if L2TPv3 interface already exists + if l2tpv3['ifname'] in interfaces(): + # L2TPv3 is picky when changing tunnels/sessions, thus we can simply + # always delete it first. + l = L2TPv3If(**l2tpv3) + l.remove() + + if 'deleted' not in l2tpv3: + # Finally create the new interface + l = L2TPv3If(**l2tpv3) + l.update(l2tpv3) + + return None + +if __name__ == '__main__': + try: + check_kmod(k_mod) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_loopback.py b/src/conf_mode/interfaces_loopback.py new file mode 100755 index 000000000..08d34477a --- /dev/null +++ b/src/conf_mode/interfaces_loopback.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 . + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import LoopbackIf +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'loopback'] + _, loopback = get_interface_dict(conf, base) + return loopback + +def verify(loopback): + verify_mirror_redirect(loopback) + return None + +def generate(loopback): + return None + +def apply(loopback): + l = LoopbackIf(**loopback) + if 'deleted' in loopback: + l.remove() + else: + l.update(loopback) + + 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/interfaces_macsec.py b/src/conf_mode/interfaces_macsec.py new file mode 100755 index 000000000..0a927ac88 --- /dev/null +++ b/src/conf_mode/interfaces_macsec.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-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 . + +import os + +from netifaces import interfaces +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.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import MACsecIf +from vyos.ifconfig import Interface +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.process import is_systemd_service_running +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +wpa_suppl_conf = '/run/wpa_supplicant/{source_interface}.conf' + +# Constants +## gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit +GCM_AES_128_LEN: int = 32 +GCM_128_KEY_ERROR = 'gcm-aes-128 requires a 128bit long key!' +## gcm-aes-256 requires a 256bit long key - 64 characters (string) = 32byte = 256bit +GCM_AES_256_LEN: int = 64 +GCM_256_KEY_ERROR = 'gcm-aes-256 requires a 256bit long key!' + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'macsec'] + ifname, macsec = get_interface_dict(conf, base) + + # Check if interface has been removed + if 'deleted' in macsec: + source_interface = conf.return_effective_value(base + [ifname, 'source-interface']) + macsec.update({'source_interface': source_interface}) + + if is_node_changed(conf, base + [ifname, 'security']): + macsec.update({'shutdown_required': {}}) + + if is_node_changed(conf, base + [ifname, 'source_interface']): + macsec.update({'shutdown_required': {}}) + + if 'source_interface' in macsec: + tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) + if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + + return macsec + + +def verify(macsec): + if 'deleted' in macsec: + verify_bridge_delete(macsec) + return None + + verify_source_interface(macsec) + verify_vrf(macsec) + verify_mtu_ipv6(macsec) + verify_address(macsec) + verify_bond_bridge_member(macsec) + verify_mirror_redirect(macsec) + + if dict_search('security.cipher', macsec) == None: + raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) + + if dict_search('security.encrypt', macsec) != None: + # Check that only static or MKA config is present + if dict_search('security.static', macsec) != None and (dict_search('security.mka.cak', macsec) != None or dict_search('security.mka.ckn', macsec) != None): + raise ConfigError('Only static or MKA can be used!') + + # Logic to check static configuration + if dict_search('security.static', macsec) != None: + # tx-key must be defined + if dict_search('security.static.key', macsec) == None: + raise ConfigError('Static MACsec tx-key must be defined.') + + tx_len = len(dict_search('security.static.key', macsec)) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and tx_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) + + if dict_search('security.cipher', macsec) == 'gcm-aes-256' and tx_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) + + # Make sure at least one peer is defined + if 'peer' not in macsec['security']['static']: + raise ConfigError('Must have at least one peer defined for static MACsec') + + # For every enabled peer, make sure a MAC and rx-key is defined + for peer, peer_config in macsec['security']['static']['peer'].items(): + if 'disable' not in peer_config and ('mac' not in peer_config or 'key' not in peer_config): + raise ConfigError('Every enabled MACsec static peer must have a MAC address and rx-key defined.') + + # check rx-key length against cipher suite + rx_len = len(peer_config['key']) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and rx_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) + + if dict_search('security.cipher', macsec) == 'gcm-aes-256' and rx_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) + + # Logic to check MKA configuration + else: + if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: + raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') + + cak_len = len(dict_search('security.mka.cak', macsec)) + + if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != GCM_AES_128_LEN: + raise ConfigError(GCM_128_KEY_ERROR) + + elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != GCM_AES_256_LEN: + raise ConfigError(GCM_256_KEY_ERROR) + + if 'source_interface' in macsec: + # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad + # and 802.1q) - we need to check the underlaying MTU if our configured + # MTU is at least 40 bytes less then the MTU of our physical interface. + lower_mtu = Interface(macsec['source_interface']).get_mtu() + if lower_mtu < (int(macsec['mtu']) + 40): + raise ConfigError('MACsec overhead does not fit into underlaying device MTU,\n' \ + f'{lower_mtu} bytes is too small!') + + return None + + +def generate(macsec): + # Only generate wpa_supplicant config if using MKA + if dict_search('security.mka.cak', macsec): + render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec) + return None + + +def apply(macsec): + systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec) + + # Remove macsec interface on deletion or mandatory parameter change + if 'deleted' in macsec or 'shutdown_required' in macsec: + call(f'systemctl stop {systemd_service}') + + if macsec['ifname'] in interfaces(): + tmp = MACsecIf(macsec['ifname']) + tmp.remove() + + if 'deleted' in macsec: + # delete configuration on interface removal + if os.path.isfile(wpa_suppl_conf.format(**macsec)): + os.unlink(wpa_suppl_conf.format(**macsec)) + + return None + + # It is safe to "re-create" the interface always, there is a sanity + # check that the interface will only be create if its non existent + i = MACsecIf(**macsec) + i.update(macsec) + + # Only reload/restart if using MKA + if dict_search('security.mka.cak', macsec): + if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: + call(f'systemctl reload-or-restart {systemd_service}') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py new file mode 100755 index 000000000..bdeb44837 --- /dev/null +++ b/src/conf_mode/interfaces_openvpn.py @@ -0,0 +1,732 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os +import re +import tempfile + +from cryptography.hazmat.primitives.asymmetric import ec +from glob import glob +from sys import exit +from ipaddress import IPv4Address +from ipaddress import IPv4Network +from ipaddress import IPv6Address +from ipaddress import IPv6Network +from ipaddress import summarize_address_range +from netifaces import interfaces +from secrets import SystemRandom +from shutil import rmtree + +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.configverify import verify_vrf +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import VTunIf +from vyos.pki import load_dh_parameters +from vyos.pki import load_private_key +from vyos.pki import sort_ca_chain +from vyos.pki import verify_ca_chain +from vyos.pki import wrap_certificate +from vyos.pki import wrap_crl +from vyos.pki import wrap_dh_parameters +from vyos.pki import wrap_openvpn_key +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.list import is_list_equal +from vyos.utils.file import makedir +from vyos.utils.file import read_file +from vyos.utils.file import write_file +from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import unload_kmod +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.process import cmd +from vyos.utils.network import is_addr_assigned + +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +user = 'openvpn' +group = 'openvpn' + +cfg_dir = '/run/openvpn' +cfg_file = '/run/openvpn/{ifname}.conf' +otp_path = '/config/auth/openvpn' +otp_file = '/config/auth/openvpn/{ifname}-otp-secrets' +secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567') +service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf' + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'openvpn'] + + ifname, openvpn = get_interface_dict(conf, base) + openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) + + if 'deleted' in openvpn: + return openvpn + + openvpn['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + if is_node_changed(conf, base + [ifname, 'openvpn-option']): + openvpn.update({'restart_required': {}}) + if is_node_changed(conf, base + [ifname, 'enable-dco']): + openvpn.update({'restart_required': {}}) + + # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict' + # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there. + tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) + + # We have to cleanup the config dict, as default values could enable features + # which are not explicitly enabled on the CLI. Example: server mfa totp + # originate comes with defaults, which will enable the + # totp plugin, even when not set via CLI so we + # need to check this first and drop those keys + if dict_search('server.mfa.totp', tmp) == None: + del openvpn['server']['mfa'] + + # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all + # OpenVPN interfaces. Check if DCO is used by any other interface instance. + tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + for interface, interface_config in tmp.items(): + # If one interface has DCO configured, enable it. No need to further check + # all other OpenVPN interfaces. We must use a dedicated key to indicate + # the Kernel module must be loaded or not. The per interface "offload.dco" + # key is required per OpenVPN interface instance. + if dict_search('offload.dco', interface_config) != None: + openvpn['module_load_dco'] = {} + break + + return openvpn + +def is_ec_private_key(pki, cert_name): + if not pki or 'certificate' not in pki: + return False + if cert_name not in pki['certificate']: + return False + + pki_cert = pki['certificate'][cert_name] + if 'private' not in pki_cert or 'key' not in pki_cert['private']: + return False + + key = load_private_key(pki_cert['private']['key']) + return isinstance(key, ec.EllipticCurvePrivateKey) + +def verify_pki(openvpn): + pki = openvpn['pki'] + interface = openvpn['ifname'] + mode = openvpn['mode'] + shared_secret_key = dict_search_args(openvpn, 'shared_secret_key') + tls = dict_search_args(openvpn, 'tls') + + if not bool(shared_secret_key) ^ bool(tls): # xor check if only one is set + raise ConfigError('Must specify only one of "shared-secret-key" and "tls"') + + if mode in ['server', 'client'] and not tls: + raise ConfigError('Must specify "tls" for server and client modes') + + if not pki: + raise ConfigError('PKI is not configured') + + if shared_secret_key: + if not dict_search_args(pki, 'openvpn', 'shared_secret'): + raise ConfigError('There are no openvpn shared-secrets in PKI configuration') + + if shared_secret_key not in pki['openvpn']['shared_secret']: + raise ConfigError(f'Invalid shared-secret on openvpn interface {interface}') + + # If PSK settings are correct, warn about its deprecation + DeprecationWarning("OpenVPN shared-secret support will be removed in future VyOS versions.\n\ + Please migrate your site-to-site tunnels to TLS.\n\ + You can use self-signed certificates with peer fingerprint verification, consult the documentation for details.") + + if tls: + if (mode in ['server', 'client']) and ('ca_certificate' not in tls): + raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface},\ + it is required in server and client modes') + else: + if ('ca_certificate' not in tls) and ('peer_fingerprint' not in tls): + raise ConfigError('Either "tls ca-certificate" or "tls peer-fingerprint" is required\ + on openvpn interface {interface} in site-to-site mode') + + if 'ca_certificate' in tls: + for ca_name in tls['ca_certificate']: + if ca_name not in pki['ca']: + raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + + if len(tls['ca_certificate']) > 1: + sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca']) + if not verify_ca_chain(sorted_chain, pki['ca']): + raise ConfigError(f'CA certificates are not a valid chain') + + if mode != 'client' and 'auth_key' not in tls: + if 'certificate' not in tls: + raise ConfigError(f'Missing "tls certificate" on openvpn interface {interface}') + + if 'certificate' in tls: + if tls['certificate'] not in pki['certificate']: + raise ConfigError(f'Invalid certificate on openvpn interface {interface}') + + if dict_search_args(pki, 'certificate', tls['certificate'], 'private', 'password_protected') is not None: + raise ConfigError(f'Cannot use encrypted private key on openvpn interface {interface}') + + if 'dh_params' in tls: + pki_dh = pki['dh'][tls['dh_params']] + dh_params = load_dh_parameters(pki_dh['parameters']) + dh_numbers = dh_params.parameter_numbers() + dh_bits = dh_numbers.p.bit_length() + + if dh_bits < 2048: + raise ConfigError(f'Minimum DH key-size is 2048 bits') + + + if 'auth_key' in tls or 'crypt_key' in tls: + if not dict_search_args(pki, 'openvpn', 'shared_secret'): + raise ConfigError('There are no openvpn shared-secrets in PKI configuration') + + if 'auth_key' in tls: + if tls['auth_key'] not in pki['openvpn']['shared_secret']: + raise ConfigError(f'Invalid auth-key on openvpn interface {interface}') + + if 'crypt_key' in tls: + if tls['crypt_key'] not in pki['openvpn']['shared_secret']: + raise ConfigError(f'Invalid crypt-key on openvpn interface {interface}') + +def verify(openvpn): + if 'deleted' in openvpn: + # remove totp secrets file if totp is not configured + if os.path.isfile(otp_file.format(**openvpn)): + os.remove(otp_file.format(**openvpn)) + + verify_bridge_delete(openvpn) + return None + + if 'mode' not in openvpn: + raise ConfigError('Must specify OpenVPN operation mode!') + + # + # OpenVPN client mode - VERIFY + # + if openvpn['mode'] == 'client': + if 'local_port' in openvpn: + raise ConfigError('Cannot specify "local-port" in client mode') + + if 'local_host' in openvpn: + raise ConfigError('Cannot specify "local-host" in client mode') + + if 'remote_host' not in openvpn: + raise ConfigError('Must specify "remote-host" in client mode') + + if openvpn['protocol'] == 'tcp-passive': + raise ConfigError('Protocol "tcp-passive" is not valid in client mode') + + if dict_search('tls.dh_params', openvpn): + raise ConfigError('Cannot specify "tls dh-params" in client mode') + + # + # OpenVPN site-to-site - VERIFY + # + elif openvpn['mode'] == 'site-to-site': + if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn: + raise ConfigError('Must specify "local-address" or add interface to bridge') + + if 'local_address' in openvpn: + if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1: + raise ConfigError('Only one IPv4 local-address can be specified') + + if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1: + raise ConfigError('Only one IPv6 local-address can be specified') + + if openvpn['device_type'] == 'tun': + if 'remote_address' not in openvpn: + raise ConfigError('Must specify "remote-address"') + + if 'remote_address' in openvpn: + if len([addr for addr in openvpn['remote_address'] if is_ipv4(addr)]) > 1: + raise ConfigError('Only one IPv4 remote-address can be specified') + + if len([addr for addr in openvpn['remote_address'] if is_ipv6(addr)]) > 1: + raise ConfigError('Only one IPv6 remote-address can be specified') + + if not 'local_address' in openvpn: + raise ConfigError('"remote-address" requires "local-address"') + + v4loAddr = [addr for addr in openvpn['local_address'] if is_ipv4(addr)] + v4remAddr = [addr for addr in openvpn['remote_address'] if is_ipv4(addr)] + if v4loAddr and not v4remAddr: + raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address"') + elif v4remAddr and not v4loAddr: + raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"') + + v6remAddr = [addr for addr in openvpn['remote_address'] if is_ipv6(addr)] + v6loAddr = [addr for addr in openvpn['local_address'] if is_ipv6(addr)] + if v6loAddr and not v6remAddr: + raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"') + elif v6remAddr and not v6loAddr: + raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"') + + if is_list_equal(v4loAddr, v4remAddr) or is_list_equal(v6loAddr, v6remAddr): + raise ConfigError('"local-address" and "remote-address" cannot be the same') + + if dict_search('local_host', openvpn) in dict_search('local_address', openvpn): + raise ConfigError('"local-address" cannot be the same as "local-host"') + + if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn): + raise ConfigError('"remote-address" and "remote-host" can not be the same') + + if openvpn['device_type'] == 'tap' and 'local_address' in openvpn: + # we can only have one local_address, this is ensured above + v4addr = None + for laddr in openvpn['local_address']: + if is_ipv4(laddr): + v4addr = laddr + break + + if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]: + raise ConfigError('Must specify IPv4 "subnet-mask" for local-address') + + if dict_search('encryption.ncp_ciphers', openvpn): + raise ConfigError('NCP ciphers can only be used in client or server mode') + + else: + # checks for client-server or site-to-site bridged + if 'local_address' in openvpn or 'remote_address' in openvpn: + raise ConfigError('Cannot specify "local-address" or "remote-address" ' \ + 'in client/server or bridge mode') + + # + # OpenVPN server mode - VERIFY + # + if openvpn['mode'] == 'server': + if openvpn['protocol'] == 'tcp-active': + raise ConfigError('Protocol "tcp-active" is not valid in server mode') + + if dict_search('authentication.username', openvpn) or dict_search('authentication.password', openvpn): + raise ConfigError('Cannot specify "authentication" in server mode') + + if 'remote_port' in openvpn: + raise ConfigError('Cannot specify "remote-port" in server mode') + + if 'remote_host' in openvpn: + raise ConfigError('Cannot specify "remote-host" in server mode') + + tmp = dict_search('server.subnet', openvpn) + if tmp: + v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)]) + v6_subnets = len([subnet for subnet in tmp if is_ipv6(subnet)]) + if v4_subnets > 1: + raise ConfigError('Cannot specify more than 1 IPv4 server subnet') + if v6_subnets > 1: + raise ConfigError('Cannot specify more than 1 IPv6 server subnet') + + for subnet in tmp: + if is_ipv4(subnet): + subnet = IPv4Network(subnet) + + if openvpn['device_type'] == 'tun' and subnet.prefixlen > 29: + raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported') + elif openvpn['device_type'] == 'tap' and subnet.prefixlen > 30: + raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported') + + for client in (dict_search('client', openvpn) or []): + if client['ip'] and not IPv4Address(client['ip'][0]) in subnet: + raise ConfigError(f'Client "{client["name"]}" IP {client["ip"][0]} not in server subnet {subnet}') + + else: + if 'is_bridge_member' not in openvpn: + raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode') + + if hasattr(dict_search('server.client', openvpn), '__iter__'): + for client_k, client_v in dict_search('server.client', openvpn).items(): + if (client_v.get('ip') and len(client_v['ip']) > 1) or (client_v.get('ipv6_ip') and len(client_v['ipv6_ip']) > 1): + raise ConfigError(f'Server client "{client_k}": cannot specify more than 1 IPv4 and 1 IPv6 IP') + + if dict_search('server.client_ip_pool', openvpn): + if not (dict_search('server.client_ip_pool.start', openvpn) and dict_search('server.client_ip_pool.stop', openvpn)): + raise ConfigError('Server client-ip-pool requires both start and stop addresses') + else: + v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn)) + v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn)) + if v4PoolStart > v4PoolStop: + raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}') + + v4PoolSize = int(v4PoolStop) - int(v4PoolStart) + if v4PoolSize >= 65536: + raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.') + + v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop)) + for client in (dict_search('client', openvpn) or []): + if client['ip']: + for v4PoolNet in v4PoolNets: + if IPv4Address(client['ip'][0]) in v4PoolNet: + print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.') + # configuring a client_ip_pool will set 'server ... nopool' which is currently incompatible with 'server-ipv6' (probably to be fixed upstream) + for subnet in (dict_search('server.subnet', openvpn) or []): + if is_ipv6(subnet): + raise ConfigError(f'Setting client-ip-pool is incompatible having an IPv6 server subnet.') + + for subnet in (dict_search('server.subnet', openvpn) or []): + if is_ipv6(subnet): + tmp = dict_search('client_ipv6_pool.base', openvpn) + if tmp: + if not dict_search('server.client_ip_pool', openvpn): + raise ConfigError('IPv6 server pool requires an IPv4 server pool') + + if int(tmp.split('/')[1]) >= 112: + raise ConfigError('IPv6 server pool must be larger than /112') + + # + # todo - weird logic + # + v6PoolStart = IPv6Address(tmp) + v6PoolStop = IPv6Network((v6PoolStart, openvpn['server_ipv6_pool_prefixlen']), strict=False)[-1] # don't remove the parentheses, it's a 2-tuple + v6PoolSize = int(v6PoolStop) - int(v6PoolStart) if int(openvpn['server_ipv6_pool_prefixlen']) > 96 else 65536 + if v6PoolSize < v4PoolSize: + raise ConfigError(f'IPv6 server pool must be at least as large as the IPv4 pool (current sizes: IPv6={v6PoolSize} IPv4={v4PoolSize})') + + v6PoolNets = list(summarize_address_range(v6PoolStart, v6PoolStop)) + for client in (dict_search('client', openvpn) or []): + if client['ipv6_ip']: + for v6PoolNet in v6PoolNets: + if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet: + print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.') + + # add mfa users to the file the mfa plugin uses + if dict_search('server.mfa.totp', openvpn): + user_data = '' + if not os.path.isfile(otp_file.format(**openvpn)): + write_file(otp_file.format(**openvpn), user_data, + user=user, group=group, mode=0o644) + + ovpn_users = read_file(otp_file.format(**openvpn)) + for client in (dict_search('server.client', openvpn) or []): + exists = None + for ovpn_user in ovpn_users.split('\n'): + if re.search('^' + client + ' ', ovpn_user): + user_data += f'{ovpn_user}\n' + exists = 'true' + + if not exists: + random = SystemRandom() + totp_secret = ''.join(random.choice(secret_chars) for _ in range(16)) + user_data += f'{client} otp totp:sha1:base32:{totp_secret}::xxx *\n' + + write_file(otp_file.format(**openvpn), user_data, + user=user, group=group, mode=0o644) + + else: + # checks for both client and site-to-site go here + if dict_search('server.reject_unconfigured_clients', openvpn): + raise ConfigError('Option reject-unconfigured-clients only supported in server mode') + + if 'replace_default_route' in openvpn and 'remote_host' not in openvpn: + raise ConfigError('Cannot set "replace-default-route" without "remote-host"') + + # + # OpenVPN common verification section + # not depending on any operation mode + # + + # verify specified IP address is present on any interface on this system + if 'local_host' in openvpn: + if not is_addr_assigned(openvpn['local_host']): + print('local-host IP address "{local_host}" not assigned' \ + ' to any interface'.format(**openvpn)) + + # TCP active + if openvpn['protocol'] == 'tcp-active': + if 'local_port' in openvpn: + raise ConfigError('Cannot specify "local-port" with "tcp-active"') + + if 'remote_host' not in openvpn: + raise ConfigError('Must specify "remote-host" with "tcp-active"') + + # + # TLS/encryption + # + if 'shared_secret_key' in openvpn: + if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']: + raise ConfigError('GCM encryption with shared-secret-key not supported') + + if 'tls' in openvpn: + if {'auth_key', 'crypt_key'} <= set(openvpn['tls']): + raise ConfigError('TLS auth and crypt keys are mutually exclusive') + + tmp = dict_search('tls.role', openvpn) + if tmp: + if openvpn['mode'] in ['client', 'server']: + if not dict_search('tls.auth_key', openvpn): + raise ConfigError('Cannot specify "tls role" in client-server mode') + + if tmp == 'active': + if openvpn['protocol'] == 'tcp-passive': + raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"') + + if dict_search('tls.dh_params', openvpn): + raise ConfigError('Cannot specify "tls dh-params" when "tls role" is "active"') + + elif tmp == 'passive': + if openvpn['protocol'] == 'tcp-active': + raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"') + + if 'certificate' in openvpn['tls'] and is_ec_private_key(openvpn['pki'], openvpn['tls']['certificate']): + if 'dh_params' in openvpn['tls']: + print('Warning: using dh-params and EC keys simultaneously will ' \ + 'lead to DH ciphers being used instead of ECDH') + + if dict_search('encryption.cipher', openvpn) == 'none': + print('Warning: "encryption none" was specified!') + print('No encryption will be performed and data is transmitted in ' \ + 'plain text over the network!') + + verify_pki(openvpn) + + # + # Auth user/pass + # + if (dict_search('authentication.username', openvpn) and not + dict_search('authentication.password', openvpn)): + raise ConfigError('Password for authentication is missing') + + if (dict_search('authentication.password', openvpn) and not + dict_search('authentication.username', openvpn)): + raise ConfigError('Username for authentication is missing') + + verify_vrf(openvpn) + verify_bond_bridge_member(openvpn) + verify_mirror_redirect(openvpn) + + return None + +def generate_pki_files(openvpn): + pki = openvpn['pki'] + if not pki: + return None + + interface = openvpn['ifname'] + shared_secret_key = dict_search_args(openvpn, 'shared_secret_key') + tls = dict_search_args(openvpn, 'tls') + + if shared_secret_key: + pki_key = pki['openvpn']['shared_secret'][shared_secret_key] + key_path = os.path.join(cfg_dir, f'{interface}_shared.key') + write_file(key_path, wrap_openvpn_key(pki_key['key']), + user=user, group=group) + + if tls: + if 'ca_certificate' in tls: + cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') + crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') + + if os.path.exists(cert_path): + os.unlink(cert_path) + + if os.path.exists(crl_path): + os.unlink(crl_path) + + for cert_name in sort_ca_chain(tls['ca_certificate'], pki['ca']): + pki_ca = pki['ca'][cert_name] + + if 'certificate' in pki_ca: + write_file(cert_path, wrap_certificate(pki_ca['certificate']) + "\n", + user=user, group=group, mode=0o600, append=True) + + if 'crl' in pki_ca: + for crl in pki_ca['crl']: + write_file(crl_path, wrap_crl(crl) + "\n", user=user, group=group, + mode=0o600, append=True) + + openvpn['tls']['crl'] = True + + if 'certificate' in tls: + cert_name = tls['certificate'] + pki_cert = pki['certificate'][cert_name] + + if 'certificate' in pki_cert: + cert_path = os.path.join(cfg_dir, f'{interface}_cert.pem') + write_file(cert_path, wrap_certificate(pki_cert['certificate']), + user=user, group=group, mode=0o600) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + key_path = os.path.join(cfg_dir, f'{interface}_cert.key') + write_file(key_path, wrap_private_key(pki_cert['private']['key']), + user=user, group=group, mode=0o600) + + openvpn['tls']['private_key'] = True + + if 'dh_params' in tls: + dh_name = tls['dh_params'] + pki_dh = pki['dh'][dh_name] + + if 'parameters' in pki_dh: + dh_path = os.path.join(cfg_dir, f'{interface}_dh.pem') + write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']), + user=user, group=group, mode=0o600) + + if 'auth_key' in tls: + key_name = tls['auth_key'] + pki_key = pki['openvpn']['shared_secret'][key_name] + + if 'key' in pki_key: + key_path = os.path.join(cfg_dir, f'{interface}_auth.key') + write_file(key_path, wrap_openvpn_key(pki_key['key']), + user=user, group=group, mode=0o600) + + if 'crypt_key' in tls: + key_name = tls['crypt_key'] + pki_key = pki['openvpn']['shared_secret'][key_name] + + if 'key' in pki_key: + key_path = os.path.join(cfg_dir, f'{interface}_crypt.key') + write_file(key_path, wrap_openvpn_key(pki_key['key']), + user=user, group=group, mode=0o600) + + +def generate(openvpn): + interface = openvpn['ifname'] + directory = os.path.dirname(cfg_file.format(**openvpn)) + openvpn['plugin_dir'] = '/usr/lib/openvpn' + # create base config directory on demand + makedir(directory, user, group) + # enforce proper permissions on /run/openvpn + chown(directory, user, group) + + # we can't know in advance which clients have been removed, + # thus all client configs will be removed and re-added on demand + ccd_dir = os.path.join(directory, 'ccd', interface) + if os.path.isdir(ccd_dir): + rmtree(ccd_dir, ignore_errors=True) + + # Remove systemd directories with overrides + service_dir = os.path.dirname(service_file.format(**openvpn)) + if os.path.isdir(service_dir): + rmtree(service_dir, ignore_errors=True) + + if 'deleted' in openvpn or 'disable' in openvpn: + return None + + # create client config directory on demand + makedir(ccd_dir, user, group) + + # Fix file permissons for keys + generate_pki_files(openvpn) + + # Generate User/Password authentication file + if 'authentication' in openvpn: + render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.j2', openvpn, + user=user, group=group, permission=0o600) + else: + # delete old auth file if present + if os.path.isfile(openvpn['auth_user_pass_file']): + os.remove(openvpn['auth_user_pass_file']) + + # Generate client specific configuration + server_client = dict_search_args(openvpn, 'server', 'client') + if server_client: + for client, client_config in server_client.items(): + client_file = os.path.join(ccd_dir, client) + + # Our client need's to know its subnet mask ... + client_config['server_subnet'] = dict_search('server.subnet', openvpn) + + render(client_file, 'openvpn/client.conf.j2', client_config, + user=user, group=group) + + # we need to support quoting of raw parameters from OpenVPN CLI + # see https://vyos.dev/T1632 + render(cfg_file.format(**openvpn), 'openvpn/server.conf.j2', openvpn, + formater=lambda _: _.replace(""", '"'), user=user, group=group) + + # Render 20-override.conf for OpenVPN service + render(service_file.format(**openvpn), 'openvpn/service-override.conf.j2', openvpn, + formater=lambda _: _.replace(""", '"'), user=user, group=group) + # Reload systemd services config to apply an override + call(f'systemctl daemon-reload') + + return None + +def apply(openvpn): + interface = openvpn['ifname'] + + # Do some cleanup when OpenVPN is disabled/deleted + if 'deleted' in openvpn or 'disable' in openvpn: + call(f'systemctl stop openvpn@{interface}.service') + for cleanup_file in glob(f'/run/openvpn/{interface}.*'): + if os.path.isfile(cleanup_file): + os.unlink(cleanup_file) + + if interface in interfaces(): + VTunIf(interface).remove() + + # dynamically load/unload DCO Kernel extension if requested + dco_module = 'ovpn_dco_v2' + if 'module_load_dco' in openvpn: + check_kmod(dco_module) + else: + unload_kmod(dco_module) + + # Now bail out early if interface is disabled or got deleted + if 'deleted' in openvpn or 'disable' in openvpn: + return None + + # verify specified IP address is present on any interface on this system + # Allow to bind service to nonlocal address, if it virtaual-vrrp address + # or if address will be assign later + if 'local_host' in openvpn: + if not is_addr_assigned(openvpn['local_host']): + cmd('sysctl -w net.ipv4.ip_nonlocal_bind=1') + + # 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') + + o = VTunIf(**openvpn) + o.update(openvpn) + + 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/interfaces_pppoe.py b/src/conf_mode/interfaces_pppoe.py new file mode 100755 index 000000000..42f084309 --- /dev/null +++ b/src/conf_mode/interfaces_pppoe.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2021 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 . + +import os + +from sys import exit +from copy import deepcopy +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configdict import get_pppoe_interfaces +from vyos.configverify import verify_authentication +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import PPPoEIf +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'pppoe'] + ifname, pppoe = get_interface_dict(conf, base) + + # We should only terminate the PPPoE session if critical parameters change. + # All parameters that can be changed on-the-fly (like interface description) + # should not lead to a reconnect! + for options in ['access-concentrator', 'connect-on-demand', 'service-name', + 'source-interface', 'vrf', 'no-default-route', + 'authentication', 'host_uniq']: + if is_node_changed(conf, base + [ifname, options]): + pppoe.update({'shutdown_required': {}}) + # bail out early - no need to further process other nodes + break + + if 'deleted' not in pppoe: + # We always set the MRU value to the MTU size. This code path only re-creates + # the old behavior if MRU is not set on the CLI. + if 'mru' not in pppoe: + pppoe['mru'] = pppoe['mtu'] + + return pppoe + +def verify(pppoe): + if 'deleted' in pppoe: + # bail out early + return None + + verify_source_interface(pppoe) + verify_authentication(pppoe) + verify_vrf(pppoe) + verify_mtu_ipv6(pppoe) + verify_mirror_redirect(pppoe) + + if {'connect_on_demand', 'vrf'} <= set(pppoe): + raise ConfigError('On-demand dialing and VRF can not be used at the same time') + + # both MTU and MRU have default values, thus we do not need to check + # if the key exists + if int(pppoe['mru']) > int(pppoe['mtu']): + raise ConfigError('PPPoE MRU needs to be lower then MTU!') + + return None + +def generate(pppoe): + # set up configuration file path variables where our templates will be + # rendered into + ifname = pppoe['ifname'] + config_pppoe = f'/etc/ppp/peers/{ifname}' + + if 'deleted' in pppoe or 'disable' in pppoe: + if os.path.exists(config_pppoe): + os.unlink(config_pppoe) + + return None + + # Create PPP configuration files + render(config_pppoe, 'pppoe/peer.j2', pppoe, permission=0o640) + + return None + +def apply(pppoe): + ifname = pppoe['ifname'] + if 'deleted' in pppoe or 'disable' in pppoe: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.remove() + call(f'systemctl stop ppp@{ifname}.service') + return None + + # reconnect should only be necessary when certain config options change, + # like ACS name, authentication ... (see get_config() for details) + if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or + 'shutdown_required' in pppoe): + + # cleanup system (e.g. FRR routes first) + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.remove() + + call(f'systemctl restart ppp@{ifname}.service') + # When interface comes "live" a hook is called: + # /etc/ppp/ip-up.d/99-vyos-pppoe-callback + # which triggers PPPoEIf.update() + else: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = PPPoEIf(ifname) + p.update(pppoe) + + 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/interfaces_pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py new file mode 100755 index 000000000..dce5c2358 --- /dev/null +++ b/src/conf_mode/interfaces_pseudo-ethernet.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2022 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 . + +from sys import exit +from netifaces import interfaces + +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.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_mtu_parent +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import MACVLANIf +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'pseudo-ethernet'] + ifname, peth = get_interface_dict(conf, base) + + mode = is_node_changed(conf, ['mode']) + if mode: peth.update({'shutdown_required' : {}}) + + if is_node_changed(conf, base + [ifname, 'mode']): + peth.update({'rebuild_required': {}}) + + if 'source_interface' in peth: + _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], + peth['source_interface']) + # test if source-interface is maybe already used by another interface + tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) + if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) + + return peth + +def verify(peth): + if 'deleted' in peth: + verify_bridge_delete(peth) + return None + + verify_source_interface(peth) + verify_vrf(peth) + verify_address(peth) + verify_mtu_parent(peth, peth['parent']) + verify_mirror_redirect(peth) + # use common function to verify VLAN configuration + verify_vlan_config(peth) + + return None + +def generate(peth): + return None + +def apply(peth): + # Check if the MACVLAN interface already exists + if 'rebuild_required' in peth or 'deleted' in peth: + if peth['ifname'] in interfaces(): + p = MACVLANIf(peth['ifname']) + # MACVLAN is always needs to be recreated, + # thus we can simply always delete it first. + p.remove() + + if 'deleted' not in peth: + p = MACVLANIf(**peth) + p.update(peth) + + 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/interfaces_sstpc.py b/src/conf_mode/interfaces_sstpc.py new file mode 100755 index 000000000..b588910dc --- /dev/null +++ b/src/conf_mode/interfaces_sstpc.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 . + +import os +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.configverify import verify_authentication +from vyos.configverify import verify_vrf +from vyos.ifconfig import SSTPCIf +from vyos.pki import encode_certificate +from vyos.pki import find_chain +from vyos.pki import load_certificate +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.process import is_systemd_service_running +from vyos.utils.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'sstpc'] + ifname, sstpc = get_interface_dict(conf, base) + + # We should only terminate the SSTP client session if critical parameters + # change. All parameters that can be changed on-the-fly (like interface + # description) should not lead to a reconnect! + for options in ['authentication', 'no_peer_dns', 'no_default_route', + 'server', 'ssl']: + if is_node_changed(conf, base + [ifname, options]): + sstpc.update({'shutdown_required': {}}) + # bail out early - no need to further process other nodes + break + + # Load PKI certificates for later processing + sstpc['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + return sstpc + +def verify(sstpc): + if 'deleted' in sstpc: + return None + + verify_authentication(sstpc) + verify_vrf(sstpc) + + if not dict_search('server', sstpc): + raise ConfigError('Remote SSTP server must be specified!') + + if not dict_search('ssl.ca_certificate', sstpc): + raise ConfigError('Missing mandatory CA certificate!') + + return None + +def generate(sstpc): + ifname = sstpc['ifname'] + config_sstpc = f'/etc/ppp/peers/{ifname}' + + sstpc['ca_file_path'] = f'/run/sstpc/{ifname}_ca-cert.pem' + + if 'deleted' in sstpc: + for file in [sstpc['ca_file_path'], config_sstpc]: + if os.path.exists(file): + os.unlink(file) + return None + + ca_name = sstpc['ssl']['ca_certificate'] + pki_ca_cert = sstpc['pki']['ca'][ca_name] + + loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) + loaded_ca_certs = {load_certificate(c['certificate']) + for c in sstpc['pki']['ca'].values()} if 'ca' in sstpc['pki'] else {} + + ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) + + write_file(sstpc['ca_file_path'], '\n'.join(encode_certificate(c) for c in ca_full_chain)) + render(config_sstpc, 'sstp-client/peer.j2', sstpc, permission=0o640) + + return None + +def apply(sstpc): + ifname = sstpc['ifname'] + if 'deleted' in sstpc or 'disable' in sstpc: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.remove() + call(f'systemctl stop ppp@{ifname}.service') + return None + + # reconnect should only be necessary when specific options change, + # like server, authentication ... (see get_config() for details) + if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or + 'shutdown_required' in sstpc): + + # cleanup system (e.g. FRR routes first) + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.remove() + + call(f'systemctl restart ppp@{ifname}.service') + # When interface comes "live" a hook is called: + # /etc/ppp/ip-up.d/96-vyos-sstpc-callback + # which triggers SSTPCIf.update() + else: + if os.path.isdir(f'/sys/class/net/{ifname}'): + p = SSTPCIf(ifname) + p.update(sstpc) + + 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/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py new file mode 100755 index 000000000..91aed9cc3 --- /dev/null +++ b/src/conf_mode/interfaces_tunnel.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 yOS 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 . + +import os + +from sys import exit +from netifaces import interfaces + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_vrf +from vyos.configverify import verify_tunnel +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import Interface +from vyos.ifconfig import Section +from vyos.ifconfig import TunnelIf +from vyos.utils.network import get_interface_config +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least + the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'tunnel'] + ifname, tunnel = get_interface_dict(conf, base) + + if 'deleted' not in tunnel: + tmp = is_node_changed(conf, base + [ifname, 'encapsulation']) + if tmp: tunnel.update({'encapsulation_changed': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'parameters', 'ip', 'key']) + if tmp: tunnel.update({'key_changed': {}}) + + # We also need to inspect other configured tunnels as there are Kernel + # restrictions where we need to comply. E.g. GRE tunnel key can't be used + # twice, or with multiple GRE tunnels to the same location we must specify + # a GRE key + conf.set_level(base) + tunnel['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + # delete our own instance from this dict + ifname = tunnel['ifname'] + del tunnel['other_tunnels'][ifname] + # if only one tunnel is present on the system, no need to keep this key + if len(tunnel['other_tunnels']) == 0: + del tunnel['other_tunnels'] + + # We must check if our interface is configured to be a DMVPN member + nhrp_base = ['protocols', 'nhrp', 'tunnel'] + conf.set_level(nhrp_base) + nhrp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True) + if nhrp: tunnel.update({'nhrp' : list(nhrp.keys())}) + + if 'encapsulation' in tunnel and tunnel['encapsulation'] not in ['erspan', 'ip6erspan']: + del tunnel['parameters']['erspan'] + + return tunnel + +def verify(tunnel): + if 'deleted' in tunnel: + verify_bridge_delete(tunnel) + + if 'nhrp' in tunnel and tunnel['ifname'] in tunnel['nhrp']: + raise ConfigError('Tunnel used for NHRP, it can not be deleted!') + + return None + + verify_tunnel(tunnel) + + if tunnel['encapsulation'] in ['erspan', 'ip6erspan']: + if dict_search('parameters.ip.key', tunnel) == None: + raise ConfigError('ERSPAN requires ip key parameter!') + + # this is a default field + ver = int(tunnel['parameters']['erspan']['version']) + if ver == 1: + if 'hw_id' in tunnel['parameters']['erspan']: + raise ConfigError('ERSPAN version 1 does not support hw-id!') + if 'direction' in tunnel['parameters']['erspan']: + raise ConfigError('ERSPAN version 1 does not support direction!') + elif ver == 2: + if 'idx' in tunnel['parameters']['erspan']: + raise ConfigError('ERSPAN version 2 does not index parameter!') + if 'direction' not in tunnel['parameters']['erspan']: + raise ConfigError('ERSPAN version 2 requires direction to be set!') + + # If tunnel source is any and gre key is not set + interface = tunnel['ifname'] + if tunnel['encapsulation'] in ['gre'] and \ + dict_search('source_address', tunnel) == '0.0.0.0' and \ + dict_search('parameters.ip.key', tunnel) == None: + raise ConfigError(f'"parameters ip key" must be set for {interface} when '\ + 'encapsulation is GRE!') + + gre_encapsulations = ['gre', 'gretap'] + if tunnel['encapsulation'] in gre_encapsulations and 'other_tunnels' in tunnel: + # Check pairs tunnel source-address/encapsulation/key with exists tunnels. + # Prevent the same key for 2 tunnels with same source-address/encap. T2920 + for o_tunnel, o_tunnel_conf in tunnel['other_tunnels'].items(): + # no match on encapsulation - bail out + our_encapsulation = tunnel['encapsulation'] + their_encapsulation = o_tunnel_conf['encapsulation'] + if our_encapsulation in gre_encapsulations and their_encapsulation \ + not in gre_encapsulations: + continue + + our_address = dict_search('source_address', tunnel) + our_key = dict_search('parameters.ip.key', tunnel) + their_address = dict_search('source_address', o_tunnel_conf) + their_key = dict_search('parameters.ip.key', o_tunnel_conf) + if our_key != None: + if their_address == our_address and their_key == our_key: + raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" ' \ + f'is already used for tunnel "{o_tunnel}"!') + else: + our_source_if = dict_search('source_interface', tunnel) + their_source_if = dict_search('source_interface', o_tunnel_conf) + our_remote = dict_search('remote', tunnel) + their_remote = dict_search('remote', o_tunnel_conf) + # If no IP GRE key is defined we can not have more then one GRE tunnel + # bound to any one interface/IP address and the same remote. This will + # result in a OS PermissionError: add tunnel "gre0" failed: File exists + if (their_address == our_address or our_source_if == their_source_if) and \ + our_remote == their_remote: + raise ConfigError(f'Missing required "ip key" parameter when '\ + 'running more then one GRE based tunnel on the '\ + 'same source-interface/source-address') + + # Keys are not allowed with ipip and sit tunnels + if tunnel['encapsulation'] in ['ipip', 'sit']: + if dict_search('parameters.ip.key', tunnel) != None: + raise ConfigError('Keys are not allowed with ipip and sit tunnels!') + + verify_mtu_ipv6(tunnel) + verify_address(tunnel) + verify_vrf(tunnel) + verify_bond_bridge_member(tunnel) + verify_mirror_redirect(tunnel) + + if 'source_interface' in tunnel: + verify_interface_exists(tunnel['source_interface']) + + # TTL != 0 and nopmtudisc are incompatible, parameters and ip use default + # values, thus the keys are always present. + if dict_search('parameters.ip.no_pmtu_discovery', tunnel) != None: + if dict_search('parameters.ip.ttl', tunnel) != '0': + raise ConfigError('Disabled PMTU requires TTL set to "0"!') + if tunnel['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']: + raise ConfigError('Can not disable PMTU discovery for given encapsulation') + + if dict_search('parameters.ip.ignore_df', tunnel) != None: + if tunnel['encapsulation'] not in ['gretap']: + raise ConfigError('Option ignore-df can only be used on GRETAP tunnels!') + + if dict_search('parameters.ip.no_pmtu_discovery', tunnel) == None: + raise ConfigError('Option ignore-df requires path MTU discovery to be disabled!') + + +def generate(tunnel): + return None + +def apply(tunnel): + interface = tunnel['ifname'] + # If a gretap tunnel is already existing we can not "simply" change local or + # remote addresses. This returns "Operation not supported" by the Kernel. + # There is no other solution to destroy and recreate the tunnel. + encap = '' + remote = '' + tmp = get_interface_config(interface) + if tmp: + encap = dict_search('linkinfo.info_kind', tmp) + remote = dict_search('linkinfo.info_data.remote', tmp) + + if ('deleted' in tunnel or 'encapsulation_changed' in tunnel or encap in + ['gretap', 'ip6gretap', 'erspan', 'ip6erspan'] or remote in ['any'] or + 'key_changed' in tunnel): + if interface in interfaces(): + tmp = Interface(interface) + tmp.remove() + if 'deleted' in tunnel: + return None + + tun = TunnelIf(**tunnel) + tun.update(tunnel) + + return None + +if __name__ == '__main__': + try: + c = get_config() + generate(c) + verify(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py new file mode 100755 index 000000000..8efe89c41 --- /dev/null +++ b/src/conf_mode/interfaces_virtual-ethernet.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 . + +from sys import exit + +from netifaces import interfaces +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_vrf +from vyos.ifconfig import VethIf + +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at + least the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'virtual-ethernet'] + ifname, veth = get_interface_dict(conf, base) + + # We need to know all other veth related interfaces as veth requires a 1:1 + # mapping for the peer-names. The Linux kernel automatically creates both + # interfaces, the local one and the peer-name, but VyOS also needs a peer + # interfaces configrued on the CLI so we can assign proper IP addresses etc. + veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return veth + + +def verify(veth): + if 'deleted' in veth: + verify_bridge_delete(veth) + # Prevent to delete veth interface which used for another "vethX peer-name" + for iface, iface_config in veth['other_interfaces'].items(): + if veth['ifname'] in iface_config['peer_name']: + ifname = veth['ifname'] + raise ConfigError( + f'Cannot delete "{ifname}" used for "interface {iface} peer-name"' + ) + return None + + verify_vrf(veth) + verify_address(veth) + + if 'peer_name' not in veth: + raise ConfigError(f'Remote peer name must be set for "{veth["ifname"]}"!') + + peer_name = veth['peer_name'] + ifname = veth['ifname'] + + if veth['peer_name'] not in veth['other_interfaces']: + raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ + 'is not configured!') + + if veth['other_interfaces'][peer_name]['peer_name'] != ifname: + raise ConfigError( + f'Configuration mismatch between "{ifname}" and "{peer_name}"!') + + if peer_name == ifname: + raise ConfigError( + f'Peer-name "{peer_name}" cannot be the same as interface "{ifname}"!') + + return None + + +def generate(peth): + return None + +def apply(veth): + # Check if the Veth interface already exists + if 'rebuild_required' in veth or 'deleted' in veth: + if veth['ifname'] in interfaces(): + p = VethIf(veth['ifname']) + p.remove() + + if 'deleted' not in veth: + p = VethIf(**veth) + p.update(veth) + + 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/interfaces_vti.py b/src/conf_mode/interfaces_vti.py new file mode 100755 index 000000000..9871810ae --- /dev/null +++ b/src/conf_mode/interfaces_vti.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 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 . + +from netifaces import interfaces +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_mirror_redirect +from vyos.ifconfig import VTIIf +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'vti'] + _, vti = get_interface_dict(conf, base) + return vti + +def verify(vti): + verify_mirror_redirect(vti) + return None + +def generate(vti): + return None + +def apply(vti): + # Remove macsec interface + if 'deleted' in vti: + VTIIf(**vti).remove() + return None + + tmp = VTIIf(**vti) + tmp.update(vti) + + 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/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py new file mode 100755 index 000000000..4251e611b --- /dev/null +++ b/src/conf_mode/interfaces_vxlan.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os + +from sys import exit +from netifaces import interfaces + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed +from vyos.configdict import node_changed +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_source_interface +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import Interface +from vyos.ifconfig import VXLANIf +from vyos.template import is_ipv6 +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least + the interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'vxlan'] + ifname, vxlan = get_interface_dict(conf, base) + + # VXLAN interfaces are picky and require recreation if certain parameters + # change. But a VXLAN 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 ['parameters', 'gpe', 'group', 'port', 'remote', + 'source-address', 'source-interface', 'vni']: + if is_node_changed(conf, base + [ifname, cli_option]): + vxlan.update({'rebuild_required': {}}) + break + + # When dealing with VNI filtering we need to know what VNI was actually removed, + # so build up a dict matching the vlan_to_vni structure but with removed values. + tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True) + if tmp: + vxlan.update({'vlan_to_vni_removed': {}}) + for vlan in tmp: + vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) + vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) + + # We need to verify that no other VXLAN tunnel is configured when external + # mode is in use - Linux Kernel limitation + conf.set_level(base) + vxlan['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # This if-clause is just to be sure - it will always evaluate to true + ifname = vxlan['ifname'] + if ifname in vxlan['other_tunnels']: + del vxlan['other_tunnels'][ifname] + if len(vxlan['other_tunnels']) == 0: + del vxlan['other_tunnels'] + + return vxlan + +def verify(vxlan): + if 'deleted' in vxlan: + verify_bridge_delete(vxlan) + return None + + if int(vxlan['mtu']) < 1500: + Warning('RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU') + + if 'group' in vxlan: + if 'source_interface' not in vxlan: + raise ConfigError('Multicast VXLAN requires an underlaying interface') + verify_source_interface(vxlan) + + if not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan): + raise ConfigError('Group, remote, source-address or source-interface must be configured') + + if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None: + raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!') + + if dict_search('parameters.external', vxlan) != None: + if 'vni' in vxlan: + raise ConfigError('Can not specify both "external" and "VNI"!') + + if 'other_tunnels' in vxlan: + # When multiple VXLAN interfaces are defined and "external" is used, + # all VXLAN interfaces need to have vni-filter enabled! + # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9 + other_vni_filter = False + for tunnel, tunnel_config in vxlan['other_tunnels'].items(): + if dict_search('parameters.vni_filter', tunnel_config) != None: + other_vni_filter = True + break + # eqivalent of the C foo ? 'a' : 'b' statement + vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False + # If either one is enabled, so must be the other. Both can be off and both can be on + if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): + raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\ + 'requires all VXLAN interfaces to have "vni-filter" configured!') + + if not vni_filter and not other_vni_filter: + other_tunnels = ', '.join(vxlan['other_tunnels']) + raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ + f'CLI option is used and "vni-filter" is unset. '\ + f'Additional tunnels: {other_tunnels}') + + if 'gpe' in vxlan and 'external' not in vxlan: + raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ + f'CLI option is used.') + + if 'source_interface' in vxlan: + # VXLAN adds at least an overhead of 50 byte - we need to check the + # underlaying device if our VXLAN package is not going to be fragmented! + vxlan_overhead = 50 + if 'source_address' in vxlan and is_ipv6(vxlan['source_address']): + # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 + # bytes larger than the IPv4 header - assuming no extra options are + # in use. + vxlan_overhead += 20 + + # If source_address is not used - check IPv6 'remote' list + elif 'remote' in vxlan: + if any(is_ipv6(a) for a in vxlan['remote']): + vxlan_overhead += 20 + + lower_mtu = Interface(vxlan['source_interface']).get_mtu() + if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): + raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ + f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') + + # Check for mixed IPv4 and IPv6 addresses + protocol = None + if 'source_address' in vxlan: + if is_ipv6(vxlan['source_address']): + protocol = 'ipv6' + else: + protocol = 'ipv4' + + if 'remote' in vxlan: + error_msg = 'Can not mix both IPv4 and IPv6 for VXLAN underlay' + for remote in vxlan['remote']: + if is_ipv6(remote): + if protocol == 'ipv4': + raise ConfigError(error_msg) + protocol = 'ipv6' + else: + if protocol == 'ipv6': + raise ConfigError(error_msg) + protocol = 'ipv4' + + if 'vlan_to_vni' in vxlan: + if 'is_bridge_member' not in vxlan: + raise ConfigError('VLAN to VNI mapping requires that VXLAN interface '\ + 'is member of a bridge interface!') + + vnis_used = [] + for vif, vif_config in vxlan['vlan_to_vni'].items(): + if 'vni' not in vif_config: + raise ConfigError(f'Must define VNI for VLAN "{vif}"!') + vni = vif_config['vni'] + if vni in vnis_used: + raise ConfigError(f'VNI "{vni}" is already assigned to a different VLAN!') + vnis_used.append(vni) + + if dict_search('parameters.neighbor_suppress', vxlan) != None: + if 'is_bridge_member' not in vxlan: + raise ConfigError('Neighbor suppression requires that VXLAN interface '\ + 'is member of a bridge interface!') + + verify_mtu_ipv6(vxlan) + verify_address(vxlan) + verify_bond_bridge_member(vxlan) + verify_mirror_redirect(vxlan) + + # We use a defaultValue for port, thus it's always safe to use + if vxlan['port'] == '8472': + Warning('Starting from VyOS 1.4, the default port for VXLAN '\ + 'has been changed to 4789. This matches the IANA assigned '\ + 'standard port number!') + + return None + +def generate(vxlan): + return None + +def apply(vxlan): + # Check if the VXLAN interface already exists + if 'rebuild_required' in vxlan or 'delete' in vxlan: + if vxlan['ifname'] in interfaces(): + v = VXLANIf(vxlan['ifname']) + # VXLAN is super picky and the tunnel always needs to be recreated, + # thus we can simply always delete it first. + v.remove() + + if 'deleted' not in vxlan: + # Finally create the new interface + v = VXLANIf(**vxlan) + v.update(vxlan) + + 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/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py new file mode 100755 index 000000000..79e5d3f44 --- /dev/null +++ b/src/conf_mode/interfaces_wireguard.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mtu_ipv6 +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import WireGuardIf +from vyos.utils.kernel import check_kmod +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_wireguard_key_pair +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'wireguard'] + ifname, wireguard = get_interface_dict(conf, base) + + # Check if a port was changed + tmp = is_node_changed(conf, base + [ifname, 'port']) + if tmp: wireguard['port_changed'] = {} + + # T4702: If anything on a peer changes we remove the peer first and re-add it + if is_node_changed(conf, base + [ifname, 'peer']): + wireguard.update({'rebuild_required': {}}) + + return wireguard + +def verify(wireguard): + if 'deleted' in wireguard: + verify_bridge_delete(wireguard) + return None + + verify_mtu_ipv6(wireguard) + verify_address(wireguard) + verify_vrf(wireguard) + verify_bond_bridge_member(wireguard) + verify_mirror_redirect(wireguard) + + if 'private_key' not in wireguard: + raise ConfigError('Wireguard private-key not defined') + + if 'peer' not in wireguard: + raise ConfigError('At least one Wireguard peer is required!') + + if 'port' in wireguard and 'port_changed' in wireguard: + listen_port = int(wireguard['port']) + if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: + raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' + 'cannot be used for the interface!') + + # run checks on individual configured WireGuard peer + public_keys = [] + for tmp in wireguard['peer']: + peer = wireguard['peer'][tmp] + + if 'allowed_ips' not in peer: + raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') + + 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!') + + if peer['public_key'] in public_keys: + raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + + 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"]}"') + + public_keys.append(peer['public_key']) + +def apply(wireguard): + if 'rebuild_required' in wireguard or 'deleted' in wireguard: + wg = WireGuardIf(**wireguard) + # WireGuard only supports peer removal based on the configured public-key, + # by deleting the entire interface this is the shortcut instead of parsing + # out all peers and removing them one by one. + # + # Peer reconfiguration will always come with a short downtime while the + # WireGuard interface is recreated (see below) + wg.remove() + + # Create the new interface if required + if 'deleted' not in wireguard: + wg = WireGuardIf(**wireguard) + wg.update(wireguard) + + return None + +if __name__ == '__main__': + try: + check_kmod('wireguard') + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py new file mode 100755 index 000000000..02b4a2500 --- /dev/null +++ b/src/conf_mode/interfaces_wireless.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 . + +import os + +from sys import exit +from re import findall +from netaddr import EUI, mac_unix_expanded + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import dict_merge +from vyos.configverify import verify_address +from vyos.configverify import verify_bridge_delete +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_vlan_config +from vyos.configverify import verify_vrf +from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import WiFiIf +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +# XXX: wpa_supplicant works on the source interface +wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf' +hostapd_conf = '/run/hostapd/{ifname}.conf' +hostapd_accept_station_conf = '/run/hostapd/{ifname}_station_accept.conf' +hostapd_deny_station_conf = '/run/hostapd/{ifname}_station_deny.conf' + +def find_other_stations(conf, base, ifname): + """ + Only one wireless interface per phy can be in station mode - + find all interfaces attached to a phy which run in station mode + """ + old_level = conf.get_level() + conf.set_level(base) + dict = {} + for phy in os.listdir('/sys/class/ieee80211'): + list = [] + for interface in conf.list_nodes([]): + if interface == ifname: + continue + # the following node is mandatory + if conf.exists([interface, 'physical-device', phy]): + tmp = conf.return_value([interface, 'type']) + if tmp == 'station': + list.append(interface) + if list: + dict.update({phy: list}) + conf.set_level(old_level) + return dict + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'wireless'] + + ifname, wifi = get_interface_dict(conf, base) + + if 'deleted' not in wifi: + # then get_interface_dict provides default keys + if wifi.from_defaults(['security', 'wep']): # if not set by user + del wifi['security']['wep'] + if wifi.from_defaults(['security', 'wpa']): # if not set by user + del wifi['security']['wpa'] + + if dict_search('security.wpa', wifi) != None: + wpa_cipher = wifi['security']['wpa'].get('cipher') + wpa_mode = wifi['security']['wpa'].get('mode') + if not wpa_cipher: + tmp = None + if wpa_mode == 'wpa': + tmp = {'security': {'wpa': {'cipher' : ['TKIP', 'CCMP']}}} + elif wpa_mode == 'wpa2': + tmp = {'security': {'wpa': {'cipher' : ['CCMP']}}} + elif wpa_mode == 'both': + tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'TKIP']}}} + + if tmp: wifi = dict_merge(tmp, wifi) + + # Only one wireless interface per phy can be in station mode + tmp = find_other_stations(conf, base, wifi['ifname']) + if tmp: wifi['station_interfaces'] = tmp + + # used in hostapt.conf.j2 + wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi) + wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi) + + return wifi + +def verify(wifi): + if 'deleted' in wifi: + verify_bridge_delete(wifi) + return None + + if 'physical_device' not in wifi: + raise ConfigError('You must specify a physical-device "phy"') + + if 'type' not in wifi: + raise ConfigError('You must specify a WiFi mode') + + if 'ssid' not in wifi and wifi['type'] != 'monitor': + raise ConfigError('SSID must be configured unless type is set to "monitor"!') + + if wifi['type'] == 'access-point': + if 'country_code' not in wifi: + raise ConfigError('Wireless country-code is mandatory') + + if 'channel' not in wifi: + raise ConfigError('Wireless channel must be configured!') + + if 'security' in wifi: + if {'wep', 'wpa'} <= set(wifi.get('security', {})): + raise ConfigError('Must either use WEP or WPA security!') + + if 'wep' in wifi['security']: + if 'key' in wifi['security']['wep'] and len(wifi['security']['wep']) > 4: + raise ConfigError('No more then 4 WEP keys configurable') + elif 'key' not in wifi['security']['wep']: + raise ConfigError('Security WEP configured - missing WEP keys!') + + elif 'wpa' in wifi['security']: + wpa = wifi['security']['wpa'] + if not any(i in ['passphrase', 'radius'] for i in wpa): + raise ConfigError('Misssing WPA key or RADIUS server') + + if 'radius' in wpa: + if 'server' in wpa['radius']: + for server in wpa['radius']['server']: + if 'key' not in wpa['radius']['server'][server]: + raise ConfigError(f'Misssing RADIUS shared secret key for server: {server}') + + if 'capabilities' in wifi: + capabilities = wifi['capabilities'] + if 'vht' in capabilities: + if 'ht' not in capabilities: + raise ConfigError('Specify HT flags if you want to use VHT!') + + if {'beamform', 'antenna_count'} <= set(capabilities.get('vht', {})): + if capabilities['vht']['antenna_count'] == '1': + raise ConfigError('Cannot use beam forming with just one antenna!') + + if capabilities['vht']['beamform'] == 'single-user-beamformer': + if int(capabilities['vht']['antenna_count']) < 3: + # Nasty Gotcha: see https://w1.fi/cgit/hostap/plain/hostapd/hostapd.conf lines 692-705 + raise ConfigError('Single-user beam former requires at least 3 antennas!') + + if 'station_interfaces' in wifi and wifi['type'] == 'station': + phy = wifi['physical_device'] + if phy in wifi['station_interfaces']: + if len(wifi['station_interfaces'][phy]) > 0: + raise ConfigError('Only one station per wireless physical interface possible!') + + verify_address(wifi) + verify_vrf(wifi) + verify_bond_bridge_member(wifi) + verify_mirror_redirect(wifi) + + # use common function to verify VLAN configuration + verify_vlan_config(wifi) + + return None + +def generate(wifi): + interface = wifi['ifname'] + + # always stop hostapd service first before reconfiguring it + call(f'systemctl stop hostapd@{interface}.service') + # always stop wpa_supplicant service first before reconfiguring it + call(f'systemctl stop wpa_supplicant@{interface}.service') + + # Delete config files if interface is removed + if 'deleted' in wifi: + if os.path.isfile(hostapd_conf.format(**wifi)): + os.unlink(hostapd_conf.format(**wifi)) + if os.path.isfile(hostapd_accept_station_conf.format(**wifi)): + os.unlink(hostapd_accept_station_conf.format(**wifi)) + if os.path.isfile(hostapd_deny_station_conf.format(**wifi)): + os.unlink(hostapd_deny_station_conf.format(**wifi)) + if os.path.isfile(wpa_suppl_conf.format(**wifi)): + os.unlink(wpa_suppl_conf.format(**wifi)) + + return None + + if 'mac' not in wifi: + # http://wiki.stocksy.co.uk/wiki/Multiple_SSIDs_with_hostapd + # generate locally administered MAC address from used phy interface + with open('/sys/class/ieee80211/{physical_device}/addresses'.format(**wifi), 'r') as f: + # some PHYs tend to have multiple interfaces and thus supply multiple MAC + # addresses - we only need the first one for our calculation + tmp = f.readline().rstrip() + tmp = EUI(tmp).value + # mask last nibble from the MAC address + tmp &= 0xfffffffffff0 + # set locally administered bit in MAC address + tmp |= 0x020000000000 + # we now need to add an offset to our MAC address indicating this + # subinterfaces index + tmp += int(findall(r'\d+', interface)[0]) + + # convert integer to "real" MAC address representation + mac = EUI(hex(tmp).split('x')[-1]) + # change dialect to use : as delimiter instead of - + mac.dialect = mac_unix_expanded + wifi['mac'] = str(mac) + + # XXX: Jinja2 can not operate on a dictionary key when it starts of with a number + if '40mhz_incapable' in (dict_search('capabilities.ht', wifi) or []): + wifi['capabilities']['ht']['fourtymhz_incapable'] = wifi['capabilities']['ht']['40mhz_incapable'] + del wifi['capabilities']['ht']['40mhz_incapable'] + + # render appropriate new config files depending on access-point or station mode + if wifi['type'] == 'access-point': + render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.j2', wifi) + render(hostapd_accept_station_conf.format(**wifi), 'wifi/hostapd_accept_station.conf.j2', wifi) + render(hostapd_deny_station_conf.format(**wifi), 'wifi/hostapd_deny_station.conf.j2', wifi) + + elif wifi['type'] == 'station': + render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.j2', wifi) + + return None + +def apply(wifi): + interface = wifi['ifname'] + if 'deleted' in wifi: + WiFiIf(interface).remove() + else: + # Finally create the new interface + w = WiFiIf(**wifi) + w.update(wifi) + + # Enable/Disable interface - interface is always placed in + # administrative down state in WiFiIf class + if 'disable' not in wifi: + # Physical interface is now configured. Proceed by starting hostapd or + # wpa_supplicant daemon. When type is monitor we can just skip this. + if wifi['type'] == 'access-point': + call(f'systemctl start hostapd@{interface}.service') + + elif wifi['type'] == 'station': + call(f'systemctl start wpa_supplicant@{interface}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py new file mode 100755 index 000000000..2515dc838 --- /dev/null +++ b/src/conf_mode/interfaces_wwan.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2022 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 . + +import os + +from sys import exit +from time import sleep + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdict import is_node_changed +from vyos.configverify import verify_authentication +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_mirror_redirect +from vyos.configverify import verify_vrf +from vyos.ifconfig import WWANIf +from vyos.utils.dict import dict_search +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import DEVNULL +from vyos.utils.process import is_systemd_service_active +from vyos.utils.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +service_name = 'ModemManager.service' +cron_script = '/etc/cron.d/vyos-wwan' + +def get_config(config=None): + """ + Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + interface name will be added or a deleted flag + """ + if config: + conf = config + else: + conf = Config() + base = ['interfaces', 'wwan'] + ifname, wwan = get_interface_dict(conf, base) + + # We should only terminate the WWAN session if critical parameters change. + # All parameters that can be changed on-the-fly (like interface description) + # should not lead to a reconnect! + tmp = is_node_changed(conf, base + [ifname, 'address']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'apn']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'disable']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'vrf']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'authentication']) + if tmp: wwan.update({'shutdown_required': {}}) + + tmp = is_node_changed(conf, base + [ifname, 'ipv6', 'address', 'autoconf']) + if tmp: wwan.update({'shutdown_required': {}}) + + # We need to know the amount of other WWAN interfaces as ModemManager needs + # to be started or stopped. + wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # This if-clause is just to be sure - it will always evaluate to true + if ifname in wwan['other_interfaces']: + del wwan['other_interfaces'][ifname] + if len(wwan['other_interfaces']) == 0: + del wwan['other_interfaces'] + + return wwan + +def verify(wwan): + if 'deleted' in wwan: + return None + + ifname = wwan['ifname'] + if not 'apn' in wwan: + raise ConfigError(f'No APN configured for "{ifname}"!') + + verify_interface_exists(ifname) + verify_authentication(wwan) + verify_vrf(wwan) + verify_mirror_redirect(wwan) + + return None + +def generate(wwan): + if 'deleted' in wwan: + # We are the last WWAN interface - there are no other ones remaining + # thus the cronjob needs to go away, too + if 'other_interfaces' not in wwan: + if os.path.exists(cron_script): + os.unlink(cron_script) + return None + + # Install cron triggered helper script to re-dial WWAN interfaces on + # disconnect - e.g. happens during RF signal loss. The script watches every + # WWAN interface - so there is only one instance. + if not os.path.exists(cron_script): + write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n') + + return None + +def apply(wwan): + # ModemManager is required to dial WWAN connections - one instance is + # required to serve all modems. Activate ModemManager on first invocation + # of any WWAN interface. + if not is_systemd_service_active(service_name): + cmd(f'systemctl start {service_name}') + + counter = 100 + # Wait until a modem is detected and then we can continue + while counter > 0: + counter -= 1 + tmp = cmd('mmcli -L') + if tmp != 'No modems were found': + break + sleep(0.250) + + if 'shutdown_required' in wwan: + # we only need the modem number. wwan0 -> 0, wwan1 -> 1 + modem = wwan['ifname'].lstrip('wwan') + base_cmd = f'mmcli --modem {modem}' + # Number of bearers is limited - always disconnect first + cmd(f'{base_cmd} --simple-disconnect') + + w = WWANIf(wwan['ifname']) + if 'deleted' in wwan or 'disable' in wwan: + w.remove() + + # We are the last WWAN interface - there are no other WWAN interfaces + # remaining, thus we can stop ModemManager and free resources. + if 'other_interfaces' not in wwan: + cmd(f'systemctl stop {service_name}') + # Clean CRON helper script which is used for to re-connect when + # RF signal is lost + if os.path.exists(cron_script): + os.unlink(cron_script) + + return None + + if 'shutdown_required' in wwan: + ip_type = 'ipv4' + slaac = dict_search('ipv6.address.autoconf', wwan) != None + if 'address' in wwan: + if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac): + ip_type = 'ipv4v6' + elif 'dhcpv6' in wwan['address'] or slaac: + ip_type = 'ipv6' + elif 'dhcp' in wwan['address']: + ip_type = 'ipv4' + + options = f'ip-type={ip_type},apn=' + wwan['apn'] + if 'authentication' in wwan: + options += ',user={username},password={password}'.format(**wwan['authentication']) + + command = f'{base_cmd} --simple-connect="{options}"' + call(command, stdout=DEVNULL) + + w.update(wwan) + 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/le_cert.py b/src/conf_mode/le_cert.py deleted file mode 100755 index 06c7e7b72..000000000 --- a/src/conf_mode/le_cert.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-2020 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 . - -import sys -import os - -import vyos.defaults -from vyos.config import Config -from vyos import ConfigError -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_running - -from vyos import airbag -airbag.enable() - -vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] -vyos_certbot_dir = vyos.defaults.directories['certbot'] - -dependencies = [ - 'https.py', -] - -def request_certbot(cert): - email = cert.get('email') - if email is not None: - email_flag = '-m {0}'.format(email) - else: - email_flag = '' - - domains = cert.get('domains') - if domains is not None: - domain_flag = '-d ' + ' -d '.join(domains) - else: - domain_flag = '' - - certbot_cmd = f'certbot certonly --config-dir {vyos_certbot_dir} -n --nginx --agree-tos --no-eff-email --expand {email_flag} {domain_flag}' - - cmd(certbot_cmd, - raising=ConfigError, - message="The certbot request failed for the specified domains.") - -def get_config(): - conf = Config() - if not conf.exists('service https certificates certbot'): - return None - else: - conf.set_level('service https certificates certbot') - - cert = {} - - if conf.exists('domain-name'): - cert['domains'] = conf.return_values('domain-name') - - if conf.exists('email'): - cert['email'] = conf.return_value('email') - - return cert - -def verify(cert): - if cert is None: - return None - - if 'domains' not in cert: - raise ConfigError("At least one domain name is required to" - " request a letsencrypt certificate.") - - if 'email' not in cert: - raise ConfigError("An email address is required to request" - " a letsencrypt certificate.") - -def generate(cert): - if cert is None: - return None - - # certbot will attempt to reload nginx, even with 'certonly'; - # start nginx if not active - if not is_systemd_service_running('nginx.service'): - call('systemctl start nginx.service') - - request_certbot(cert) - -def apply(cert): - if cert is not None: - call('systemctl restart certbot.timer') - else: - call('systemctl stop certbot.timer') - return None - - for dep in dependencies: - cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) - diff --git a/src/conf_mode/lldp.py b/src/conf_mode/lldp.py deleted file mode 100755 index 3c647a0e8..000000000 --- a/src/conf_mode/lldp.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2017-2022 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 . - -import os - -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.utils.network import is_addr_assigned -from vyos.utils.network import is_loopback_addr -from vyos.version import get_version_data -from vyos.utils.process import call -from vyos.utils.dict import dict_search -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = "/etc/default/lldpd" -vyos_config_file = "/etc/lldpd.d/01-vyos.conf" -base = ['service', 'lldp'] - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - if not conf.exists(base): - return {} - - lldp = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - if conf.exists(['service', 'snmp']): - lldp['system_snmp_enabled'] = '' - - version_data = get_version_data() - lldp['version'] = version_data['version'] - - # prune location information if not set by user - for interface in lldp.get('interface', []): - if lldp.from_defaults(['interface', interface, 'location']): - del lldp['interface'][interface]['location'] - elif lldp.from_defaults(['interface', interface, 'location','coordinate_based']): - del lldp['interface'][interface]['location']['coordinate_based'] - - return lldp - -def verify(lldp): - # bail out early - looks like removal from running config - if lldp is None: - return - - if 'management_address' in lldp: - for address in lldp['management_address']: - message = f'LLDP management address "{address}" is invalid' - if is_loopback_addr(address): - Warning(f'{message} - loopback address') - elif not is_addr_assigned(address): - Warning(f'{message} - not assigned to any interface') - - if 'interface' in lldp: - for interface, interface_config in lldp['interface'].items(): - # bail out early if no location info present in interface config - if 'location' not in interface_config: - continue - if 'coordinate_based' in interface_config['location']: - if not {'latitude', 'latitude'} <= set(interface_config['location']['coordinate_based']): - raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!') - - # check options - if 'snmp' in lldp: - if 'system_snmp_enabled' not in lldp: - raise ConfigError('SNMP must be configured to enable LLDP SNMP!') - - -def generate(lldp): - # bail out early - looks like removal from running config - if lldp is None: - return - - render(config_file, 'lldp/lldpd.j2', lldp) - render(vyos_config_file, 'lldp/vyos.conf.j2', lldp) - -def apply(lldp): - systemd_service = 'lldpd.service' - if lldp: - # start/restart lldp service - call(f'systemctl restart {systemd_service}') - else: - # LLDP service has been terminated - call(f'systemctl stop {systemd_service}') - if os.path.isfile(config_file): - os.unlink(config_file) - if os.path.isfile(vyos_config_file): - os.unlink(vyos_config_file) - -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/load-balancing-haproxy.py b/src/conf_mode/load-balancing-haproxy.py deleted file mode 100755 index 333ebc66c..000000000 --- a/src/conf_mode/load-balancing-haproxy.py +++ /dev/null @@ -1,169 +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 . - -import os - -from sys import exit -from shutil import rmtree - -from vyos.config import Config -from vyos.utils.process import call -from vyos.utils.network import check_port_availability -from vyos.utils.network import is_listen_port_bind_service -from vyos.pki import wrap_certificate -from vyos.pki import wrap_private_key -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -load_balancing_dir = '/run/haproxy' -load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' -systemd_service = 'haproxy.service' -systemd_override = r'/run/systemd/system/haproxy.service.d/10-override.conf' - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['load-balancing', 'reverse-proxy'] - lb = conf.get_config_dict(base, - get_first_key=True, - key_mangling=('-', '_'), - no_tag_node_value_mangle=True) - - if lb: - lb['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - if lb: - lb = conf.merge_defaults(lb, recursive=True) - - return lb - - -def verify(lb): - if not lb: - return None - - if 'backend' not in lb or 'service' not in lb: - raise ConfigError(f'"service" and "backend" must be configured!') - - for front, front_config in lb['service'].items(): - if 'port' not in front_config: - raise ConfigError(f'"{front} service port" must be configured!') - - # Check if bind address:port are used by another service - tmp_address = front_config.get('address', '0.0.0.0') - tmp_port = front_config['port'] - if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ - not is_listen_port_bind_service(int(tmp_port), 'haproxy'): - raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') - - for back, back_config in lb['backend'].items(): - if 'server' not in back_config: - raise ConfigError(f'"{back} server" must be configured!') - for bk_server, bk_server_conf in back_config['server'].items(): - if 'address' not in bk_server_conf or 'port' not in bk_server_conf: - raise ConfigError(f'"backend {back} server {bk_server} address and port" must be configured!') - - if {'send_proxy', 'send_proxy_v2'} <= set(bk_server_conf): - raise ConfigError(f'Cannot use both "send-proxy" and "send-proxy-v2" for server "{bk_server}"') - -def generate(lb): - if not lb: - # Delete /run/haproxy/haproxy.cfg - config_files = [load_balancing_conf_file, systemd_override] - for file in config_files: - if os.path.isfile(file): - os.unlink(file) - # Delete old directories - if os.path.isdir(load_balancing_dir): - rmtree(load_balancing_dir, ignore_errors=True) - - return None - - # Create load-balance dir - if not os.path.isdir(load_balancing_dir): - os.mkdir(load_balancing_dir) - - # SSL Certificates for frontend - for front, front_config in lb['service'].items(): - if 'ssl' in front_config: - - if 'certificate' in front_config['ssl']: - cert_names = front_config['ssl']['certificate'] - - for cert_name in cert_names: - pki_cert = lb['pki']['certificate'][cert_name] - cert_file_path = os.path.join(load_balancing_dir, f'{cert_name}.pem') - cert_key_path = os.path.join(load_balancing_dir, f'{cert_name}.pem.key') - - with open(cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_cert['certificate'])) - - if 'private' in pki_cert and 'key' in pki_cert['private']: - with open(cert_key_path, 'w') as f: - f.write(wrap_private_key(pki_cert['private']['key'])) - - if 'ca_certificate' in front_config['ssl']: - ca_name = front_config['ssl']['ca_certificate'] - pki_ca_cert = lb['pki']['ca'][ca_name] - ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') - - with open(ca_cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_ca_cert['certificate'])) - - # SSL Certificates for backend - for back, back_config in lb['backend'].items(): - if 'ssl' in back_config: - - if 'ca_certificate' in back_config['ssl']: - ca_name = back_config['ssl']['ca_certificate'] - pki_ca_cert = lb['pki']['ca'][ca_name] - ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') - - with open(ca_cert_file_path, 'w') as f: - f.write(wrap_certificate(pki_ca_cert['certificate'])) - - render(load_balancing_conf_file, 'load-balancing/haproxy.cfg.j2', lb) - render(systemd_override, 'load-balancing/override_haproxy.conf.j2', lb) - - return None - - -def apply(lb): - call('systemctl daemon-reload') - if not lb: - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/load-balancing-wan.py b/src/conf_mode/load-balancing-wan.py deleted file mode 100755 index 5da0b906b..000000000 --- a/src/conf_mode/load-balancing-wan.py +++ /dev/null @@ -1,151 +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 . - -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' - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['load-balancing', 'wan'] - lb = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - # prune limit key if not set by user - for rule in lb.get('rule', []): - if lb.from_defaults(['rule', rule, 'limit']): - del lb['rule'][rule]['limit'] - - set_dependents('conntrack', conf) - - return lb - - -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!' - ) - 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"') - - -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') - - return None - - # Create load-balance dir - if not os.path.isdir(load_balancing_dir): - os.mkdir(load_balancing_dir) - - render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', 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}") - - else: - cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') - cmd(f'systemctl restart {systemd_service}') - - call_dependents() - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/load-balancing_reverse-proxy.py b/src/conf_mode/load-balancing_reverse-proxy.py new file mode 100755 index 000000000..333ebc66c --- /dev/null +++ b/src/conf_mode/load-balancing_reverse-proxy.py @@ -0,0 +1,169 @@ +#!/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 . + +import os + +from sys import exit +from shutil import rmtree + +from vyos.config import Config +from vyos.utils.process import call +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +load_balancing_dir = '/run/haproxy' +load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' +systemd_service = 'haproxy.service' +systemd_override = r'/run/systemd/system/haproxy.service.d/10-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'reverse-proxy'] + lb = conf.get_config_dict(base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True) + + if lb: + lb['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + if lb: + lb = conf.merge_defaults(lb, recursive=True) + + return lb + + +def verify(lb): + if not lb: + return None + + if 'backend' not in lb or 'service' not in lb: + raise ConfigError(f'"service" and "backend" must be configured!') + + for front, front_config in lb['service'].items(): + if 'port' not in front_config: + raise ConfigError(f'"{front} service port" must be configured!') + + # Check if bind address:port are used by another service + tmp_address = front_config.get('address', '0.0.0.0') + tmp_port = front_config['port'] + if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ + not is_listen_port_bind_service(int(tmp_port), 'haproxy'): + raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') + + for back, back_config in lb['backend'].items(): + if 'server' not in back_config: + raise ConfigError(f'"{back} server" must be configured!') + for bk_server, bk_server_conf in back_config['server'].items(): + if 'address' not in bk_server_conf or 'port' not in bk_server_conf: + raise ConfigError(f'"backend {back} server {bk_server} address and port" must be configured!') + + if {'send_proxy', 'send_proxy_v2'} <= set(bk_server_conf): + raise ConfigError(f'Cannot use both "send-proxy" and "send-proxy-v2" for server "{bk_server}"') + +def generate(lb): + if not lb: + # Delete /run/haproxy/haproxy.cfg + config_files = [load_balancing_conf_file, systemd_override] + for file in config_files: + if os.path.isfile(file): + os.unlink(file) + # Delete old directories + if os.path.isdir(load_balancing_dir): + rmtree(load_balancing_dir, ignore_errors=True) + + return None + + # Create load-balance dir + if not os.path.isdir(load_balancing_dir): + os.mkdir(load_balancing_dir) + + # SSL Certificates for frontend + for front, front_config in lb['service'].items(): + if 'ssl' in front_config: + + if 'certificate' in front_config['ssl']: + cert_names = front_config['ssl']['certificate'] + + for cert_name in cert_names: + pki_cert = lb['pki']['certificate'][cert_name] + cert_file_path = os.path.join(load_balancing_dir, f'{cert_name}.pem') + cert_key_path = os.path.join(load_balancing_dir, f'{cert_name}.pem.key') + + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + with open(cert_key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) + + if 'ca_certificate' in front_config['ssl']: + ca_name = front_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + # SSL Certificates for backend + for back, back_config in lb['backend'].items(): + if 'ssl' in back_config: + + if 'ca_certificate' in back_config['ssl']: + ca_name = back_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem') + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + render(load_balancing_conf_file, 'load-balancing/haproxy.cfg.j2', lb) + render(systemd_override, 'load-balancing/override_haproxy.conf.j2', lb) + + return None + + +def apply(lb): + call('systemctl daemon-reload') + if not lb: + call(f'systemctl stop {systemd_service}') + else: + call(f'systemctl reload-or-restart {systemd_service}') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py new file mode 100755 index 000000000..5da0b906b --- /dev/null +++ b/src/conf_mode/load-balancing_wan.py @@ -0,0 +1,151 @@ +#!/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 . + +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' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + # prune limit key if not set by user + for rule in lb.get('rule', []): + if lb.from_defaults(['rule', rule, 'limit']): + del lb['rule'][rule]['limit'] + + set_dependents('conntrack', conf) + + return lb + + +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!' + ) + 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"') + + +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') + + return None + + # Create load-balance dir + if not os.path.isdir(load_balancing_dir): + os.mkdir(load_balancing_dir) + + render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', 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}") + + else: + cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') + cmd(f'systemctl restart {systemd_service}') + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/ntp.py b/src/conf_mode/ntp.py deleted file mode 100755 index 1cc23a7df..000000000 --- a/src/conf_mode/ntp.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from vyos.config import Config -from vyos.configdict import is_node_changed -from vyos.configverify import verify_vrf -from vyos.configverify import verify_interface_exists -from vyos.utils.process import call -from vyos.utils.permission import chmod_750 -from vyos.utils.network import get_interface_config -from vyos.template import render -from vyos.template import is_ipv4 -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/chrony/chrony.conf' -systemd_override = r'/run/systemd/system/chrony.service.d/override.conf' -user_group = '_chrony' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'ntp'] - if not conf.exists(base): - return None - - ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - ntp['config_file'] = config_file - ntp['user'] = user_group - - tmp = is_node_changed(conf, base + ['vrf']) - if tmp: ntp.update({'restart_required': {}}) - - return ntp - -def verify(ntp): - # bail out early - looks like removal from running config - if not ntp: - return None - - if 'server' not in ntp: - raise ConfigError('NTP server not configured') - - verify_vrf(ntp) - - if 'interface' in ntp: - # If ntpd should listen on a given interface, ensure it exists - interface = ntp['interface'] - verify_interface_exists(interface) - - # If we run in a VRF, our interface must belong to this VRF, too - if 'vrf' in ntp: - tmp = get_interface_config(interface) - vrf_name = ntp['vrf'] - if 'master' not in tmp or tmp['master'] != vrf_name: - raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ - f'does not belong to this VRF!') - - if 'listen_address' in ntp: - ipv4_addresses = 0 - ipv6_addresses = 0 - for address in ntp['listen_address']: - if is_ipv4(address): - ipv4_addresses += 1 - else: - ipv6_addresses += 1 - if ipv4_addresses > 1: - raise ConfigError(f'NTP Only admits one ipv4 value for listen-address parameter ') - if ipv6_addresses > 1: - raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ') - - return None - -def generate(ntp): - # bail out early - looks like removal from running config - if not ntp: - return None - - render(config_file, 'chrony/chrony.conf.j2', ntp, user=user_group, group=user_group) - render(systemd_override, 'chrony/override.conf.j2', ntp, user=user_group, group=user_group) - - # Ensure proper permission for chrony command socket - config_dir = os.path.dirname(config_file) - chmod_750(config_dir) - - return None - -def apply(ntp): - systemd_service = 'chrony.service' - # Reload systemd manager configuration - call('systemctl daemon-reload') - - if not ntp: - # NTP support is removed in the commit - call(f'systemctl stop {systemd_service}') - if os.path.exists(config_file): - os.unlink(config_file) - if os.path.isfile(systemd_override): - os.unlink(systemd_override) - return - - # we need to restart the service if e.g. the VRF name changed - systemd_action = 'reload-or-restart' - if 'restart_required' in ntp: - systemd_action = 'restart' - - call(f'systemctl {systemd_action} {systemd_service}') - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 34ba2fe69..f7e14aa16 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -36,22 +36,22 @@ sync_search = [ { 'keys': ['certificate'], 'path': ['service', 'https'], - 'script': '/usr/libexec/vyos/conf_mode/https.py' + 'script': '/usr/libexec/vyos/conf_mode/service_https.py' }, { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces-ethernet.py' + 'script': '/usr/libexec/vyos/conf_mode/interfaces_ethernet.py' }, { 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'], 'path': ['interfaces', 'openvpn'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces-openvpn.py' + 'script': '/usr/libexec/vyos/conf_mode/interfaces_openvpn.py' }, { 'keys': ['ca_certificate'], 'path': ['interfaces', 'sstpc'], - 'script': '/usr/libexec/vyos/conf_mode/interfaces-sstpc.py' + 'script': '/usr/libexec/vyos/conf_mode/interfaces_sstpc.py' }, { 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'], diff --git a/src/conf_mode/policy-local-route.py b/src/conf_mode/policy-local-route.py deleted file mode 100755 index 91e4fce2c..000000000 --- a/src/conf_mode/policy-local-route.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-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 . - -import os - -from itertools import product -from sys import exit - -from netifaces import interfaces -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configdict import node_changed -from vyos.configdict import leaf_node_changed -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - - -def get_config(config=None): - - if config: - conf = config - else: - conf = Config() - base = ['policy'] - - pbr = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - for route in ['local_route', 'local_route6']: - dict_id = 'rule_remove' if route == 'local_route' else 'rule6_remove' - route_key = 'local-route' if route == 'local_route' else 'local-route6' - base_rule = base + [route_key, 'rule'] - - # delete policy local-route - dict = {} - tmp = node_changed(conf, base_rule, key_mangling=('-', '_')) - if tmp: - for rule in (tmp or []): - src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) - src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) - fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) - iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) - dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) - dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) - table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) - proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) - rule_def = {} - if src: - rule_def = dict_merge({'source': {'address': src}}, rule_def) - if src_port: - rule_def = dict_merge({'source': {'port': src_port}}, rule_def) - if fwmk: - rule_def = dict_merge({'fwmark' : fwmk}, rule_def) - if iif: - rule_def = dict_merge({'inbound_interface' : iif}, rule_def) - if dst: - rule_def = dict_merge({'destination': {'address': dst}}, rule_def) - if dst_port: - rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) - if table: - rule_def = dict_merge({'table' : table}, rule_def) - if proto: - rule_def = dict_merge({'protocol' : proto}, rule_def) - dict = dict_merge({dict_id : {rule : rule_def}}, dict) - pbr.update(dict) - - if not route in pbr: - continue - - # delete policy local-route rule x source x.x.x.x - # delete policy local-route rule x fwmark x - # delete policy local-route rule x destination x.x.x.x - if 'rule' in pbr[route]: - for rule, rule_config in pbr[route]['rule'].items(): - src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) - src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) - fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) - iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) - dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) - dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) - table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) - proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) - # keep track of changes in configuration - # otherwise we might remove an existing node although nothing else has changed - changed = False - - rule_def = {} - # src is None if there are no changes to src - if src is None: - # if src hasn't changed, include it in the removal selector - # if a new selector is added, we have to remove all previous rules without this selector - # to make sure we remove all previous rules with this source(s), it will be included - if 'source' in rule_config: - if 'address' in rule_config['source']: - rule_def = dict_merge({'source': {'address': rule_config['source']['address']}}, rule_def) - else: - # if src is not None, it's previous content will be returned - # this can be an empty array if it's just being set, or the previous value - # either way, something has to be changed and we only want to remove previous values - changed = True - # set the old value for removal if it's not empty - if len(src) > 0: - rule_def = dict_merge({'source': {'address': src}}, rule_def) - - # source port - if src_port is None: - if 'source' in rule_config: - if 'port' in rule_config['source']: - tmp = rule_config['source']['port'] - if isinstance(tmp, str): - tmp = [tmp] - rule_def = dict_merge({'source': {'port': tmp}}, rule_def) - else: - changed = True - if len(src_port) > 0: - rule_def = dict_merge({'source': {'port': src_port}}, rule_def) - - # fwmark - if fwmk is None: - if 'fwmark' in rule_config: - tmp = rule_config['fwmark'] - if isinstance(tmp, str): - tmp = [tmp] - rule_def = dict_merge({'fwmark': tmp}, rule_def) - else: - changed = True - if len(fwmk) > 0: - rule_def = dict_merge({'fwmark' : fwmk}, rule_def) - - # inbound-interface - if iif is None: - if 'inbound_interface' in rule_config: - rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def) - else: - changed = True - if len(iif) > 0: - rule_def = dict_merge({'inbound_interface' : iif}, rule_def) - - # destination address - if dst is None: - if 'destination' in rule_config: - if 'address' in rule_config['destination']: - rule_def = dict_merge({'destination': {'address': rule_config['destination']['address']}}, rule_def) - else: - changed = True - if len(dst) > 0: - rule_def = dict_merge({'destination': {'address': dst}}, rule_def) - - # destination port - if dst_port is None: - if 'destination' in rule_config: - if 'port' in rule_config['destination']: - tmp = rule_config['destination']['port'] - if isinstance(tmp, str): - tmp = [tmp] - rule_def = dict_merge({'destination': {'port': tmp}}, rule_def) - else: - changed = True - if len(dst_port) > 0: - rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) - - # table - if table is None: - if 'set' in rule_config and 'table' in rule_config['set']: - rule_def = dict_merge({'table': [rule_config['set']['table']]}, rule_def) - else: - changed = True - if len(table) > 0: - rule_def = dict_merge({'table' : table}, rule_def) - - # protocol - if proto is None: - if 'protocol' in rule_config: - tmp = rule_config['protocol'] - if isinstance(tmp, str): - tmp = [tmp] - rule_def = dict_merge({'protocol': tmp}, rule_def) - else: - changed = True - if len(proto) > 0: - rule_def = dict_merge({'protocol' : proto}, rule_def) - - if changed: - dict = dict_merge({dict_id : {rule : rule_def}}, dict) - pbr.update(dict) - - return pbr - -def verify(pbr): - # bail out early - looks like removal from running config - if not pbr: - return None - - for route in ['local_route', 'local_route6']: - if not route in pbr: - continue - - pbr_route = pbr[route] - if 'rule' in pbr_route: - for rule in pbr_route['rule']: - if ( - 'source' not in pbr_route['rule'][rule] and - 'destination' not in pbr_route['rule'][rule] and - 'fwmark' not in pbr_route['rule'][rule] and - 'inbound_interface' not in pbr_route['rule'][rule] and - 'protocol' not in pbr_route['rule'][rule] - ): - raise ConfigError('Source or destination address or fwmark or inbound-interface or protocol is required!') - - if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']: - raise ConfigError('Table set is required!') - - if 'inbound_interface' in pbr_route['rule'][rule]: - interface = pbr_route['rule'][rule]['inbound_interface'] - if interface not in interfaces(): - raise ConfigError(f'Interface "{interface}" does not exist') - - return None - -def generate(pbr): - if not pbr: - return None - - return None - -def apply(pbr): - if not pbr: - return None - - # Delete old rule if needed - for rule_rm in ['rule_remove', 'rule6_remove']: - if rule_rm in pbr: - v6 = " -6" if rule_rm == 'rule6_remove' else "" - - for rule, rule_config in pbr[rule_rm].items(): - source = rule_config.get('source', {}).get('address', ['']) - source_port = rule_config.get('source', {}).get('port', ['']) - destination = rule_config.get('destination', {}).get('address', ['']) - destination_port = rule_config.get('destination', {}).get('port', ['']) - fwmark = rule_config.get('fwmark', ['']) - inbound_interface = rule_config.get('inbound_interface', ['']) - protocol = rule_config.get('protocol', ['']) - table = rule_config.get('table', ['']) - - for src, dst, src_port, dst_port, fwmk, iif, proto, table in product( - source, destination, source_port, destination_port, - fwmark, inbound_interface, protocol, table): - f_src = '' if src == '' else f' from {src} ' - f_src_port = '' if src_port == '' else f' sport {src_port} ' - f_dst = '' if dst == '' else f' to {dst} ' - f_dst_port = '' if dst_port == '' else f' dport {dst_port} ' - f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' - f_iif = '' if iif == '' else f' iif {iif} ' - f_proto = '' if proto == '' else f' ipproto {proto} ' - f_table = '' if table == '' else f' lookup {table} ' - - call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif}{f_table}') - - # Generate new config - for route in ['local_route', 'local_route6']: - if not route in pbr: - continue - - v6 = " -6" if route == 'local_route6' else "" - pbr_route = pbr[route] - - if 'rule' in pbr_route: - for rule, rule_config in pbr_route['rule'].items(): - table = rule_config['set'].get('table', '') - source = rule_config.get('source', {}).get('address', ['all']) - source_port = rule_config.get('source', {}).get('port', '') - destination = rule_config.get('destination', {}).get('address', ['all']) - destination_port = rule_config.get('destination', {}).get('port', '') - fwmark = rule_config.get('fwmark', '') - inbound_interface = rule_config.get('inbound_interface', '') - protocol = rule_config.get('protocol', '') - - for src in source: - f_src = f' from {src} ' if src else '' - for dst in destination: - f_dst = f' to {dst} ' if dst else '' - f_src_port = f' sport {source_port} ' if source_port else '' - f_dst_port = f' dport {destination_port} ' if destination_port else '' - f_fwmk = f' fwmark {fwmark} ' if fwmark else '' - f_iif = f' iif {inbound_interface} ' if inbound_interface else '' - f_proto = f' ipproto {protocol} ' if protocol else '' - - call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table}') - - 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/policy-route.py b/src/conf_mode/policy-route.py deleted file mode 100755 index adad012de..000000000 --- a/src/conf_mode/policy-route.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from json import loads -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.template import render -from vyos.utils.dict import dict_search_args -from vyos.utils.process import cmd -from vyos.utils.process import run -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -mark_offset = 0x7FFFFFFF -nftables_conf = '/run/nftables_policy.conf' - -valid_groups = [ - 'address_group', - 'domain_group', - 'network_group', - 'port_group', - 'interface_group' -] - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['policy'] - - policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) - - return policy - -def verify_rule(policy, name, rule_conf, ipv6, rule_id): - icmp = 'icmp' if not ipv6 else 'icmpv6' - if icmp in rule_conf: - icmp_defined = False - if 'type_name' in rule_conf[icmp]: - icmp_defined = True - if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]: - raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name') - if 'code' in rule_conf[icmp]: - icmp_defined = True - if 'type' not in rule_conf[icmp]: - raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined') - if 'type' in rule_conf[icmp]: - icmp_defined = True - - if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp: - raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP') - - if 'set' in rule_conf: - if 'tcp_mss' in rule_conf['set']: - tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') - if not tcp_flags or 'syn' not in tcp_flags: - raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') - - tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') - if tcp_flags: - if dict_search_args(rule_conf, 'protocol') != 'tcp': - raise ConfigError('Protocol must be tcp when specifying tcp flags') - - not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') - if not_flags: - duplicates = [flag for flag in tcp_flags if flag in not_flags] - if duplicates: - raise ConfigError(f'Cannot match a tcp flag as set and not set') - - for side in ['destination', 'source']: - if side in rule_conf: - side_conf = rule_conf[side] - - if 'group' in side_conf: - if len({'address_group', 'domain_group', 'network_group'} & set(side_conf['group'])) > 1: - raise ConfigError('Only one address-group, domain-group or network-group can be specified') - - for group in valid_groups: - if group in side_conf['group']: - group_name = side_conf['group'][group] - - if group_name.startswith('!'): - group_name = group_name[1:] - - fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group - error_group = fw_group.replace("_", "-") - group_obj = dict_search_args(policy['firewall_group'], fw_group, group_name) - - if group_obj is None: - raise ConfigError(f'Invalid {error_group} "{group_name}" on policy route rule') - - if not group_obj: - Warning(f'{error_group} "{group_name}" has no members') - - if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): - if 'protocol' not in rule_conf: - raise ConfigError('Protocol must be defined if specifying a port or port-group') - - if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: - raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') - -def verify(policy): - for route in ['route', 'route6']: - ipv6 = route == 'route6' - if route in policy: - for name, pol_conf in policy[route].items(): - if 'rule' in pol_conf: - for rule_id, rule_conf in pol_conf['rule'].items(): - verify_rule(policy, name, rule_conf, ipv6, rule_id) - - return None - -def generate(policy): - if not os.path.exists(nftables_conf): - policy['first_install'] = True - - render(nftables_conf, 'firewall/nftables-policy.j2', policy) - return None - -def apply_table_marks(policy): - for route in ['route', 'route6']: - if route in policy: - cmd_str = 'ip' if route == 'route' else 'ip -6' - tables = [] - for name, pol_conf in policy[route].items(): - if 'rule' in pol_conf: - for rule_id, rule_conf in pol_conf['rule'].items(): - set_table = dict_search_args(rule_conf, 'set', 'table') - if set_table: - if set_table == 'main': - set_table = '254' - if set_table in tables: - continue - tables.append(set_table) - table_mark = mark_offset - int(set_table) - cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') - -def cleanup_table_marks(): - for cmd_str in ['ip', 'ip -6']: - json_rules = cmd(f'{cmd_str} -j -N rule list') - rules = loads(json_rules) - for rule in rules: - if 'fwmark' not in rule or 'table' not in rule: - continue - fwmark = rule['fwmark'] - table = int(rule['table']) - if fwmark[:2] == '0x': - fwmark = int(fwmark, 16) - if (int(fwmark) == (mark_offset - table)): - cmd(f'{cmd_str} rule del fwmark {fwmark} table {table}') - -def apply(policy): - install_result = run(f'nft -f {nftables_conf}') - if install_result == 1: - raise ConfigError('Failed to apply policy based routing') - - if 'first_install' not in policy: - cleanup_table_marks() - - apply_table_marks(policy) - - 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/policy_local-route.py b/src/conf_mode/policy_local-route.py new file mode 100755 index 000000000..91e4fce2c --- /dev/null +++ b/src/conf_mode/policy_local-route.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-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 . + +import os + +from itertools import product +from sys import exit + +from netifaces import interfaces +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configdict import node_changed +from vyos.configdict import leaf_node_changed +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +def get_config(config=None): + + if config: + conf = config + else: + conf = Config() + base = ['policy'] + + pbr = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + for route in ['local_route', 'local_route6']: + dict_id = 'rule_remove' if route == 'local_route' else 'rule6_remove' + route_key = 'local-route' if route == 'local_route' else 'local-route6' + base_rule = base + [route_key, 'rule'] + + # delete policy local-route + dict = {} + tmp = node_changed(conf, base_rule, key_mangling=('-', '_')) + if tmp: + for rule in (tmp or []): + src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) + src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) + fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) + iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) + dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) + dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) + table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) + proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) + rule_def = {} + if src: + rule_def = dict_merge({'source': {'address': src}}, rule_def) + if src_port: + rule_def = dict_merge({'source': {'port': src_port}}, rule_def) + if fwmk: + rule_def = dict_merge({'fwmark' : fwmk}, rule_def) + if iif: + rule_def = dict_merge({'inbound_interface' : iif}, rule_def) + if dst: + rule_def = dict_merge({'destination': {'address': dst}}, rule_def) + if dst_port: + rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) + if table: + rule_def = dict_merge({'table' : table}, rule_def) + if proto: + rule_def = dict_merge({'protocol' : proto}, rule_def) + dict = dict_merge({dict_id : {rule : rule_def}}, dict) + pbr.update(dict) + + if not route in pbr: + continue + + # delete policy local-route rule x source x.x.x.x + # delete policy local-route rule x fwmark x + # delete policy local-route rule x destination x.x.x.x + if 'rule' in pbr[route]: + for rule, rule_config in pbr[route]['rule'].items(): + src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address']) + src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port']) + fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark']) + iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface']) + dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address']) + dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port']) + table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table']) + proto = leaf_node_changed(conf, base_rule + [rule, 'protocol']) + # keep track of changes in configuration + # otherwise we might remove an existing node although nothing else has changed + changed = False + + rule_def = {} + # src is None if there are no changes to src + if src is None: + # if src hasn't changed, include it in the removal selector + # if a new selector is added, we have to remove all previous rules without this selector + # to make sure we remove all previous rules with this source(s), it will be included + if 'source' in rule_config: + if 'address' in rule_config['source']: + rule_def = dict_merge({'source': {'address': rule_config['source']['address']}}, rule_def) + else: + # if src is not None, it's previous content will be returned + # this can be an empty array if it's just being set, or the previous value + # either way, something has to be changed and we only want to remove previous values + changed = True + # set the old value for removal if it's not empty + if len(src) > 0: + rule_def = dict_merge({'source': {'address': src}}, rule_def) + + # source port + if src_port is None: + if 'source' in rule_config: + if 'port' in rule_config['source']: + tmp = rule_config['source']['port'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'source': {'port': tmp}}, rule_def) + else: + changed = True + if len(src_port) > 0: + rule_def = dict_merge({'source': {'port': src_port}}, rule_def) + + # fwmark + if fwmk is None: + if 'fwmark' in rule_config: + tmp = rule_config['fwmark'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'fwmark': tmp}, rule_def) + else: + changed = True + if len(fwmk) > 0: + rule_def = dict_merge({'fwmark' : fwmk}, rule_def) + + # inbound-interface + if iif is None: + if 'inbound_interface' in rule_config: + rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def) + else: + changed = True + if len(iif) > 0: + rule_def = dict_merge({'inbound_interface' : iif}, rule_def) + + # destination address + if dst is None: + if 'destination' in rule_config: + if 'address' in rule_config['destination']: + rule_def = dict_merge({'destination': {'address': rule_config['destination']['address']}}, rule_def) + else: + changed = True + if len(dst) > 0: + rule_def = dict_merge({'destination': {'address': dst}}, rule_def) + + # destination port + if dst_port is None: + if 'destination' in rule_config: + if 'port' in rule_config['destination']: + tmp = rule_config['destination']['port'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'destination': {'port': tmp}}, rule_def) + else: + changed = True + if len(dst_port) > 0: + rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def) + + # table + if table is None: + if 'set' in rule_config and 'table' in rule_config['set']: + rule_def = dict_merge({'table': [rule_config['set']['table']]}, rule_def) + else: + changed = True + if len(table) > 0: + rule_def = dict_merge({'table' : table}, rule_def) + + # protocol + if proto is None: + if 'protocol' in rule_config: + tmp = rule_config['protocol'] + if isinstance(tmp, str): + tmp = [tmp] + rule_def = dict_merge({'protocol': tmp}, rule_def) + else: + changed = True + if len(proto) > 0: + rule_def = dict_merge({'protocol' : proto}, rule_def) + + if changed: + dict = dict_merge({dict_id : {rule : rule_def}}, dict) + pbr.update(dict) + + return pbr + +def verify(pbr): + # bail out early - looks like removal from running config + if not pbr: + return None + + for route in ['local_route', 'local_route6']: + if not route in pbr: + continue + + pbr_route = pbr[route] + if 'rule' in pbr_route: + for rule in pbr_route['rule']: + if ( + 'source' not in pbr_route['rule'][rule] and + 'destination' not in pbr_route['rule'][rule] and + 'fwmark' not in pbr_route['rule'][rule] and + 'inbound_interface' not in pbr_route['rule'][rule] and + 'protocol' not in pbr_route['rule'][rule] + ): + raise ConfigError('Source or destination address or fwmark or inbound-interface or protocol is required!') + + if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']: + raise ConfigError('Table set is required!') + + if 'inbound_interface' in pbr_route['rule'][rule]: + interface = pbr_route['rule'][rule]['inbound_interface'] + if interface not in interfaces(): + raise ConfigError(f'Interface "{interface}" does not exist') + + return None + +def generate(pbr): + if not pbr: + return None + + return None + +def apply(pbr): + if not pbr: + return None + + # Delete old rule if needed + for rule_rm in ['rule_remove', 'rule6_remove']: + if rule_rm in pbr: + v6 = " -6" if rule_rm == 'rule6_remove' else "" + + for rule, rule_config in pbr[rule_rm].items(): + source = rule_config.get('source', {}).get('address', ['']) + source_port = rule_config.get('source', {}).get('port', ['']) + destination = rule_config.get('destination', {}).get('address', ['']) + destination_port = rule_config.get('destination', {}).get('port', ['']) + fwmark = rule_config.get('fwmark', ['']) + inbound_interface = rule_config.get('inbound_interface', ['']) + protocol = rule_config.get('protocol', ['']) + table = rule_config.get('table', ['']) + + for src, dst, src_port, dst_port, fwmk, iif, proto, table in product( + source, destination, source_port, destination_port, + fwmark, inbound_interface, protocol, table): + f_src = '' if src == '' else f' from {src} ' + f_src_port = '' if src_port == '' else f' sport {src_port} ' + f_dst = '' if dst == '' else f' to {dst} ' + f_dst_port = '' if dst_port == '' else f' dport {dst_port} ' + f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' + f_iif = '' if iif == '' else f' iif {iif} ' + f_proto = '' if proto == '' else f' ipproto {proto} ' + f_table = '' if table == '' else f' lookup {table} ' + + call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif}{f_table}') + + # Generate new config + for route in ['local_route', 'local_route6']: + if not route in pbr: + continue + + v6 = " -6" if route == 'local_route6' else "" + pbr_route = pbr[route] + + if 'rule' in pbr_route: + for rule, rule_config in pbr_route['rule'].items(): + table = rule_config['set'].get('table', '') + source = rule_config.get('source', {}).get('address', ['all']) + source_port = rule_config.get('source', {}).get('port', '') + destination = rule_config.get('destination', {}).get('address', ['all']) + destination_port = rule_config.get('destination', {}).get('port', '') + fwmark = rule_config.get('fwmark', '') + inbound_interface = rule_config.get('inbound_interface', '') + protocol = rule_config.get('protocol', '') + + for src in source: + f_src = f' from {src} ' if src else '' + for dst in destination: + f_dst = f' to {dst} ' if dst else '' + f_src_port = f' sport {source_port} ' if source_port else '' + f_dst_port = f' dport {destination_port} ' if destination_port else '' + f_fwmk = f' fwmark {fwmark} ' if fwmark else '' + f_iif = f' iif {inbound_interface} ' if inbound_interface else '' + f_proto = f' ipproto {protocol} ' if protocol else '' + + call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table}') + + 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/policy_route.py b/src/conf_mode/policy_route.py new file mode 100755 index 000000000..adad012de --- /dev/null +++ b/src/conf_mode/policy_route.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from json import loads +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.template import render +from vyos.utils.dict import dict_search_args +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +mark_offset = 0x7FFFFFFF +nftables_conf = '/run/nftables_policy.conf' + +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group', + 'interface_group' +] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['policy'] + + policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + return policy + +def verify_rule(policy, name, rule_conf, ipv6, rule_id): + icmp = 'icmp' if not ipv6 else 'icmpv6' + if icmp in rule_conf: + icmp_defined = False + if 'type_name' in rule_conf[icmp]: + icmp_defined = True + if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]: + raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name') + if 'code' in rule_conf[icmp]: + icmp_defined = True + if 'type' not in rule_conf[icmp]: + raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined') + if 'type' in rule_conf[icmp]: + icmp_defined = True + + if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp: + raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP') + + if 'set' in rule_conf: + if 'tcp_mss' in rule_conf['set']: + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') + if not tcp_flags or 'syn' not in tcp_flags: + raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') + if tcp_flags: + if dict_search_args(rule_conf, 'protocol') != 'tcp': + raise ConfigError('Protocol must be tcp when specifying tcp flags') + + not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not') + if not_flags: + duplicates = [flag for flag in tcp_flags if flag in not_flags] + if duplicates: + raise ConfigError(f'Cannot match a tcp flag as set and not set') + + for side in ['destination', 'source']: + if side in rule_conf: + side_conf = rule_conf[side] + + if 'group' in side_conf: + if len({'address_group', 'domain_group', 'network_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, domain-group or network-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + + if group_name.startswith('!'): + group_name = group_name[1:] + + fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + error_group = fw_group.replace("_", "-") + group_obj = dict_search_args(policy['firewall_group'], fw_group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on policy route rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members') + + if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'): + if 'protocol' not in rule_conf: + raise ConfigError('Protocol must be defined if specifying a port or port-group') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group') + +def verify(policy): + for route in ['route', 'route6']: + ipv6 = route == 'route6' + if route in policy: + for name, pol_conf in policy[route].items(): + if 'rule' in pol_conf: + for rule_id, rule_conf in pol_conf['rule'].items(): + verify_rule(policy, name, rule_conf, ipv6, rule_id) + + return None + +def generate(policy): + if not os.path.exists(nftables_conf): + policy['first_install'] = True + + render(nftables_conf, 'firewall/nftables-policy.j2', policy) + return None + +def apply_table_marks(policy): + for route in ['route', 'route6']: + if route in policy: + cmd_str = 'ip' if route == 'route' else 'ip -6' + tables = [] + for name, pol_conf in policy[route].items(): + if 'rule' in pol_conf: + for rule_id, rule_conf in pol_conf['rule'].items(): + set_table = dict_search_args(rule_conf, 'set', 'table') + if set_table: + if set_table == 'main': + set_table = '254' + if set_table in tables: + continue + tables.append(set_table) + table_mark = mark_offset - int(set_table) + cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') + +def cleanup_table_marks(): + for cmd_str in ['ip', 'ip -6']: + json_rules = cmd(f'{cmd_str} -j -N rule list') + rules = loads(json_rules) + for rule in rules: + if 'fwmark' not in rule or 'table' not in rule: + continue + fwmark = rule['fwmark'] + table = int(rule['table']) + if fwmark[:2] == '0x': + fwmark = int(fwmark, 16) + if (int(fwmark) == (mark_offset - table)): + cmd(f'{cmd_str} rule del fwmark {fwmark} table {table}') + +def apply(policy): + install_result = run(f'nft -f {nftables_conf}') + if install_result == 1: + raise ConfigError('Failed to apply policy based routing') + + if 'first_install' not in policy: + cleanup_table_marks() + + apply_table_marks(policy) + + 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/protocols_igmp-proxy.py b/src/conf_mode/protocols_igmp-proxy.py new file mode 100755 index 000000000..40db417dd --- /dev/null +++ b/src/conf_mode/protocols_igmp-proxy.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 . + +import os + +from sys import exit +from netifaces import interfaces + +from vyos.base import Warning +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/etc/igmpproxy.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['protocols', 'igmp-proxy'] + igmp_proxy = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_defaults=True) + + if conf.exists(['protocols', 'igmp']): + igmp_proxy.update({'igmp_configured': ''}) + + if conf.exists(['protocols', 'pim']): + igmp_proxy.update({'pim_configured': ''}) + + return igmp_proxy + +def verify(igmp_proxy): + # bail out early - looks like removal from running config + if not igmp_proxy or 'disable' in igmp_proxy: + return None + + if 'igmp_configured' in igmp_proxy or 'pim_configured' in igmp_proxy: + raise ConfigError('Can not configure both IGMP proxy and PIM '\ + 'at the same time') + + # at least two interfaces are required, one upstream and one downstream + if 'interface' not in igmp_proxy or len(igmp_proxy['interface']) < 2: + raise ConfigError('Must define exactly one upstream and at least one ' \ + 'downstream interface!') + + upstream = 0 + for interface, config in igmp_proxy['interface'].items(): + if interface not in interfaces(): + raise ConfigError(f'Interface "{interface}" does not exist') + if dict_search('role', config) == 'upstream': + upstream += 1 + + if upstream == 0: + raise ConfigError('At least 1 upstream interface is required!') + elif upstream > 1: + raise ConfigError('Only 1 upstream interface allowed!') + + return None + +def generate(igmp_proxy): + # bail out early - looks like removal from running config + if not igmp_proxy: + return None + + # bail out early - service is disabled, but inform user + if 'disable' in igmp_proxy: + Warning('IGMP Proxy will be deactivated because it is disabled') + return None + + render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy) + + return None + +def apply(igmp_proxy): + if not igmp_proxy or 'disable' in igmp_proxy: + # IGMP Proxy support is removed in the commit + call('systemctl stop igmpproxy.service') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call('systemctl restart igmpproxy.service') + + 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/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py new file mode 100755 index 000000000..d865c2ac0 --- /dev/null +++ b/src/conf_mode/protocols_segment-routing.py @@ -0,0 +1,118 @@ +#!/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 . + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.template import render_to_string +from vyos.utils.dict import dict_search +from vyos.utils.system import sysctl_write +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() + + 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) + + # 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) + + import pprint + pprint.pprint(sr) + return sr + +def verify(sr): + if 'srv6' in sr: + srv6_enable = False + if 'interface' in sr: + for interface, interface_config in sr['interface'].items(): + if 'srv6' in interface_config: + srv6_enable = True + break + if not srv6_enable: + 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) + return None + +def apply(sr): + zebra_daemon = 'zebra' + + 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') + + 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') + + # 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) + + 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/protocols_segment_routing.py b/src/conf_mode/protocols_segment_routing.py deleted file mode 100755 index d865c2ac0..000000000 --- a/src/conf_mode/protocols_segment_routing.py +++ /dev/null @@ -1,118 +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 . - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configdict import node_changed -from vyos.template import render_to_string -from vyos.utils.dict import dict_search -from vyos.utils.system import sysctl_write -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() - - 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) - - # 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) - - import pprint - pprint.pprint(sr) - return sr - -def verify(sr): - if 'srv6' in sr: - srv6_enable = False - if 'interface' in sr: - for interface, interface_config in sr['interface'].items(): - if 'srv6' in interface_config: - srv6_enable = True - break - if not srv6_enable: - 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) - return None - -def apply(sr): - zebra_daemon = 'zebra' - - 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') - - 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') - - # 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) - - 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/protocols_static_arp.py b/src/conf_mode/protocols_static_arp.py new file mode 100755 index 000000000..b141f1141 --- /dev/null +++ b/src/conf_mode/protocols_static_arp.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 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 . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['protocols', 'static', 'arp'] + arp = conf.get_config_dict(base, get_first_key=True) + + if 'interface' in arp: + for interface in arp['interface']: + tmp = node_changed(conf, base + ['interface', interface, 'address'], recursive=True) + if tmp: arp['interface'][interface].update({'address_old' : tmp}) + + return arp + +def verify(arp): + pass + +def generate(arp): + pass + +def apply(arp): + if not arp: + return None + + if 'interface' in arp: + for interface, interface_config in arp['interface'].items(): + # Delete old static ARP assignments first + if 'address_old' in interface_config: + for address in interface_config['address_old']: + call(f'ip neigh del {address} dev {interface}') + + # Add new static ARP entries to interface + if 'address' not in interface_config: + continue + for address, address_config in interface_config['address'].items(): + mac = address_config['mac'] + call(f'ip neigh replace {address} lladdr {mac} dev {interface}') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/salt-minion.py b/src/conf_mode/salt-minion.py deleted file mode 100755 index a8fce8e01..000000000 --- a/src/conf_mode/salt-minion.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 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 . - -import os - -from socket import gethostname -from sys import exit -from urllib3 import PoolManager - -from vyos.base import Warning -from vyos.config import Config -from vyos.configverify import verify_interface_exists -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.permission import chown -from vyos import ConfigError - -from vyos import airbag -airbag.enable() - -config_file = r'/etc/salt/minion' -master_keyfile = r'/opt/vyatta/etc/config/salt/pki/minion/master_sign.pub' - -user='minion' -group='vyattacfg' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'salt-minion'] - - if not conf.exists(base): - return None - - salt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - # ID default is dynamic thus we can not use defaults() - if 'id' not in salt: - salt['id'] = gethostname() - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - salt = conf.merge_defaults(salt, recursive=True) - - if not conf.exists(base): - return None - else: - conf.set_level(base) - - return salt - -def verify(salt): - if not salt: - return None - - if 'hash' in salt and salt['hash'] == 'sha1': - Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!') - - if 'source_interface' in salt: - verify_interface_exists(salt['source_interface']) - - return None - -def generate(salt): - if not salt: - return None - - render(config_file, 'salt-minion/minion.j2', salt, user=user, group=group) - - if not os.path.exists(master_keyfile): - if 'master_key' in salt: - req = PoolManager().request('GET', salt['master_key'], preload_content=False) - with open(master_keyfile, 'wb') as f: - while True: - data = req.read(1024) - if not data: - break - f.write(data) - - req.release_conn() - chown(master_keyfile, user, group) - - return None - -def apply(salt): - service_name = 'salt-minion.service' - if not salt: - # Salt removed from running config - call(f'systemctl stop {service_name}') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call(f'systemctl restart {service_name}') - - 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_broadcast-relay.py b/src/conf_mode/service_broadcast-relay.py new file mode 100755 index 000000000..31c552f5a --- /dev/null +++ b/src/conf_mode/service_broadcast-relay.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-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 . + +import os + +from glob import glob +from netifaces import AF_INET +from sys import exit + +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.network import is_afi_configured +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file_base = r'/etc/default/udp-broadcast-relay' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'broadcast-relay'] + + relay = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + return relay + +def verify(relay): + if not relay or 'disabled' in relay: + return None + + for instance, config in relay.get('id', {}).items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + # we certainly require a UDP port to listen to + if 'port' not in config: + raise ConfigError(f'Port number is mandatory for UDP broadcast relay "{instance}"') + + # Relaying data without two interface is kinda senseless ... + if len(config.get('interface', [])) < 2: + raise ConfigError('At least two interfaces are required for UDP broadcast relay "{instance}"') + + for interface in config.get('interface', []): + verify_interface_exists(interface) + if not is_afi_configured(interface, AF_INET): + raise ConfigError(f'Interface "{interface}" has no IPv4 address configured!') + + return None + +def generate(relay): + if not relay or 'disabled' in relay: + return None + + for config in glob(config_file_base + '*'): + os.remove(config) + + for instance, config in relay.get('id').items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + config['instance'] = instance + render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.j2', + config) + + return None + +def apply(relay): + # first stop all running services + call('systemctl stop udp-broadcast-relay@*.service') + + if not relay or 'disable' in relay: + return None + + # start only required service instances + for instance, config in relay.get('id').items(): + # we don't have to check this instance when it's disabled + if 'disabled' in config: + continue + + call(f'systemctl start udp-broadcast-relay@{instance}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_config-sync.py b/src/conf_mode/service_config-sync.py new file mode 100755 index 000000000..4b8a7f6ee --- /dev/null +++ b/src/conf_mode/service_config-sync.py @@ -0,0 +1,105 @@ +#!/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 . + +import os +import json +from pathlib import Path + +from vyos.config import Config +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +service_conf = Path(f'/run/config_sync_conf.conf') +post_commit_dir = '/run/scripts/commit/post-hooks.d' +post_commit_file_src = '/usr/libexec/vyos/vyos_config_sync.py' +post_commit_file = f'{post_commit_dir}/vyos_config_sync' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'config-sync'] + if not conf.exists(base): + return None + config = conf.get_config_dict(base, get_first_key=True, + with_recursive_defaults=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config: + return None + + if 'mode' not in config: + raise ConfigError(f'config-sync mode is mandatory!') + + for option in ['secondary', 'section']: + if option not in config: + raise ConfigError(f"config-sync '{option}' is not configured!") + + if 'address' not in config['secondary']: + raise ConfigError(f'secondary address is mandatory!') + if 'key' not in config['secondary']: + raise ConfigError(f'secondary key is mandatory!') + + +def generate(config): + if not config: + + if os.path.exists(post_commit_file): + os.unlink(post_commit_file) + + if service_conf.exists(): + service_conf.unlink() + + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + # Create post commit dir + if not os.path.isdir(post_commit_dir): + os.makedirs(post_commit_dir) + + # Symlink from helpers to post-commit + if not os.path.exists(post_commit_file): + os.symlink(post_commit_file_src, post_commit_file) + + return None + + +def apply(config): + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_config_sync.py b/src/conf_mode/service_config_sync.py deleted file mode 100755 index 4b8a7f6ee..000000000 --- a/src/conf_mode/service_config_sync.py +++ /dev/null @@ -1,105 +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 . - -import os -import json -from pathlib import Path - -from vyos.config import Config -from vyos import ConfigError -from vyos import airbag - -airbag.enable() - - -service_conf = Path(f'/run/config_sync_conf.conf') -post_commit_dir = '/run/scripts/commit/post-hooks.d' -post_commit_file_src = '/usr/libexec/vyos/vyos_config_sync.py' -post_commit_file = f'{post_commit_dir}/vyos_config_sync' - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'config-sync'] - if not conf.exists(base): - return None - config = conf.get_config_dict(base, get_first_key=True, - with_recursive_defaults=True) - - return config - - -def verify(config): - # bail out early - looks like removal from running config - if not config: - return None - - if 'mode' not in config: - raise ConfigError(f'config-sync mode is mandatory!') - - for option in ['secondary', 'section']: - if option not in config: - raise ConfigError(f"config-sync '{option}' is not configured!") - - if 'address' not in config['secondary']: - raise ConfigError(f'secondary address is mandatory!') - if 'key' not in config['secondary']: - raise ConfigError(f'secondary key is mandatory!') - - -def generate(config): - if not config: - - if os.path.exists(post_commit_file): - os.unlink(post_commit_file) - - if service_conf.exists(): - service_conf.unlink() - - return None - - # Write configuration file - conf_json = json.dumps(config, indent=4) - service_conf.write_text(conf_json) - - # Create post commit dir - if not os.path.isdir(post_commit_dir): - os.makedirs(post_commit_dir) - - # Symlink from helpers to post-commit - if not os.path.exists(post_commit_file): - os.symlink(post_commit_file_src, post_commit_file) - - return None - - -def apply(config): - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/service_conntrack-sync.py b/src/conf_mode/service_conntrack-sync.py new file mode 100755 index 000000000..4fb2ce27f --- /dev/null +++ b/src/conf_mode/service_conntrack-sync.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 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 . + +import os + +from sys import exit +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.utils.dict import dict_search +from vyos.utils.process import process_named_running +from vyos.utils.file import read_file +from vyos.utils.process import call +from vyos.utils.process import run +from vyos.template import render +from vyos.template import get_ipv4 +from vyos.utils.network import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = '/run/conntrackd/conntrackd.conf' + +def resync_vrrp(): + tmp = run('/usr/libexec/vyos/conf_mode/high-availability.py') + if tmp > 0: + print('ERROR: error restarting VRRP daemon!') + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'conntrack-sync'] + if not conf.exists(base): + return None + + conntrack = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_defaults=True) + + conntrack['hash_size'] = read_file('/sys/module/nf_conntrack/parameters/hashsize') + conntrack['table_size'] = read_file('/proc/sys/net/netfilter/nf_conntrack_max') + + conntrack['vrrp'] = conf.get_config_dict(['high-availability', 'vrrp', 'sync-group'], + get_first_key=True) + + return conntrack + +def verify(conntrack): + if not conntrack: + return None + + if 'interface' not in conntrack: + raise ConfigError('Interface not defined!') + + has_peer = False + for interface, interface_config in conntrack['interface'].items(): + verify_interface_exists(interface) + # Interface must not only exist, it must also carry an IP address + if len(get_ipv4(interface)) < 1: + raise ConfigError(f'Interface {interface} requires an IP address!') + if 'peer' in interface_config: + has_peer = True + + # If one interface runs in unicast mode instead of multicast, so must all the + # others, else conntrackd will error out with: "cannot use UDP with other + # dedicated link protocols" + if has_peer: + for interface, interface_config in conntrack['interface'].items(): + if 'peer' not in interface_config: + raise ConfigError('Can not mix unicast and multicast mode!') + + if 'expect_sync' in conntrack: + if len(conntrack['expect_sync']) > 1 and 'all' in conntrack['expect_sync']: + raise ConfigError('Can not configure expect-sync "all" with other protocols!') + + if 'listen_address' in conntrack: + for address in conntrack['listen_address']: + if not is_addr_assigned(address): + raise ConfigError(f'Specified listen-address {address} not assigned to any interface!') + + vrrp_group = dict_search('failover_mechanism.vrrp.sync_group', conntrack) + if vrrp_group == None: + raise ConfigError(f'No VRRP sync-group defined!') + if vrrp_group not in conntrack['vrrp']: + raise ConfigError(f'VRRP sync-group {vrrp_group} not configured!') + + return None + +def generate(conntrack): + if not conntrack: + if os.path.isfile(config_file): + os.unlink(config_file) + return None + + render(config_file, 'conntrackd/conntrackd.conf.j2', conntrack) + + return None + +def apply(conntrack): + systemd_service = 'conntrackd.service' + if not conntrack: + # Failover mechanism daemon should be indicated that it no longer needs + # to execute conntrackd actions on transition. This is only required + # once when conntrackd is stopped and taken out of service! + if process_named_running('conntrackd'): + resync_vrrp() + + call(f'systemctl stop {systemd_service}') + return None + + # Failover mechanism daemon should be indicated that it needs to execute + # conntrackd actions on transition. This is only required once when conntrackd + # is started the first time! + if not process_named_running('conntrackd'): + resync_vrrp() + + call(f'systemctl reload-or-restart {systemd_service}') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_dhcp-relay.py b/src/conf_mode/service_dhcp-relay.py new file mode 100755 index 000000000..37d708847 --- /dev/null +++ b/src/conf_mode/service_dhcp-relay.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 . + +import os + +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.template import render +from vyos.base import Warning +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-relay/dhcrelay.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dhcp-relay'] + if not conf.exists(base): + return None + + relay = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if not relay or 'disable' in relay: + return None + + if 'lo' in (dict_search('interface', relay) or []): + raise ConfigError('DHCP relay does not support the loopback interface.') + + if 'server' not in relay : + raise ConfigError('No DHCP relay server(s) configured.\n' \ + 'At least one DHCP relay server required.') + + if 'interface' in relay: + Warning('DHCP relay interface is DEPRECATED - please use upstream-interface and listen-interface instead!') + if 'upstream_interface' in relay or 'listen_interface' in relay: + raise ConfigError(' configuration is not compatible with upstream/listen interface') + else: + Warning(' is going to be deprecated.\n' \ + 'Please use and ') + + if 'upstream_interface' in relay and 'listen_interface' not in relay: + raise ConfigError('No listen-interface configured') + if 'listen_interface' in relay and 'upstream_interface' not in relay: + raise ConfigError('No upstream-interface configured') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if not relay or 'disable' in relay: + return None + + render(config_file, 'dhcp-relay/dhcrelay.conf.j2', relay) + return None + +def apply(relay): + # bail out early - looks like removal from running config + service_name = 'isc-dhcp-relay.service' + if not relay or 'disable' in relay: + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + return None + + call(f'systemctl restart {service_name}') + + return None + +if __name__ == '__main__': + 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_dhcp-server.py b/src/conf_mode/service_dhcp-server.py new file mode 100755 index 000000000..7ebc560ba --- /dev/null +++ b/src/conf_mode/service_dhcp-server.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from ipaddress import ip_address +from ipaddress import ip_network +from netaddr import IPRange +from sys import exit + +from vyos.config import Config +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.file import chmod_775 +from vyos.utils.file import makedir +from vyos.utils.file import write_file +from vyos.utils.process import call +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' +ctrl_socket = '/run/kea/dhcp4-ctrl-socket' +config_file = '/run/kea/kea-dhcp4.conf' +lease_file = '/config/dhcp/dhcp4-leases.csv' +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? + + Lets assume we have a DHCP range from '192.0.2.1' to '192.0.2.100' + but want to exclude address '192.0.2.74' and '192.0.2.75'. We will + pass an input 'range_dict' in the format: + {'start' : '192.0.2.1', 'stop' : '192.0.2.100' } + and we will receive an output list of: + [{'start' : '192.0.2.1' , 'stop' : '192.0.2.73' }, + {'start' : '192.0.2.76', 'stop' : '192.0.2.100' }] + The resulting list can then be used in turn to build the proper dhcpd + configuration file. + """ + output = [] + # exclude list must be sorted for this to work + exclude_list = sorted(exclude_list) + range_start = range_dict['start'] + range_stop = range_dict['stop'] + range_last_exclude = '' + + for e in exclude_list: + 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)): + + # Build new address range ending one address before exclude address + r = { + 'start' : range_start, + 'stop' : str(ip_address(e) -1) + } + # On the next run our address range will start one address after + # the exclude address + range_start = str(ip_address(e) + 1) + + # on subsequent exclude addresses we can not + # append them to our output + if not (ip_address(r['start']) > ip_address(r['stop'])): + # Everything is fine, add range to result + output.append(r) + + # 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) + } + 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) + + return output + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dhcp-server'] + 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) + + if 'shared_network_name' in dhcp: + for network, network_config in dhcp['shared_network_name'].items(): + if 'subnet' in network_config: + for subnet, subnet_config in network_config['subnet'].items(): + # If exclude IP addresses are defined we need to slice them out of + # the defined ranges + if {'exclude', 'range'} <= set(subnet_config): + 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 + + dhcp['shared_network_name'][network]['subnet'][subnet].update( + {'range' : new_range_dict}) + + if dict_search('failover.certificate', dhcp): + 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: + return None + + # 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.') + + # Inspect shared-network/subnet + listen_ok = False + subnets = [] + failover_ok = False + shared_networks = len(dhcp['shared_network_name']) + disabled_shared_networks = 0 + + + # A shared-network requires a subnet definition + for network, network_config in dhcp['shared_network_name'].items(): + if 'disable' in network_config: + 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.') + + for subnet, subnet_config in network_config['subnet'].items(): + # All delivered static routes require a next-hop to be set + 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!') + + # 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!') + + # 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}"!') + + # 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!') + + 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!') + if stop in network: + raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!') + + tmp = IPRange(range_config['start'], range_config['stop']) + networks.append(tmp) + + # Exclude addresses must be in bound + 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}"!') + + # 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}"!') + + 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) + 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}"!') + + # There must be one subnet connected to a listen interface. + # This only counts if the network itself is not disabled! + if 'disable' not in network_config: + if is_subnet_connected(subnet, primary=False): + listen_ok = True + + # Subnets must be non overlapping + if subnet in subnets: + 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.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!') + + if 'failover' in dhcp: + for key in ['name', 'remote', 'source_address', 'status']: + if key not in dhcp['failover']: + tmp = key.replace('_', '-') + raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') + + if len({'certificate', 'ca_certificate'} & set(dhcp['failover'])) == 1: + raise ConfigError(f'DHCP secured failover requires both certificate and CA certificate') + + if 'certificate' in dhcp['failover']: + cert_name = dhcp['failover']['certificate'] + + if cert_name not in dhcp['pki']['certificate']: + raise ConfigError(f'Invalid certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'): + raise ConfigError(f'Invalid certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'): + raise ConfigError(f'Missing private key on certificate specified for DHCP failover') + + if 'ca_certificate' in dhcp['failover']: + ca_cert_name = dhcp['failover']['ca_certificate'] + if ca_cert_name not in dhcp['pki']['ca']: + raise ConfigError(f'Invalid CA certificate specified for DHCP failover') + + if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): + raise ConfigError(f'Invalid CA certificate specified for DHCP failover') + + for address in (dict_search('listen_address', dhcp) or []): + if is_addr_assigned(address): + 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') + + + 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!') + + return None + +def generate(dhcp): + # bail out early - looks like removal from running config + if not dhcp or 'disable' in dhcp: + return None + + dhcp['lease_file'] = lease_file + dhcp['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + for f in [cert_file, cert_key_file, ca_cert_file]: + if os.path.exists(f): + os.unlink(f) + + if 'failover' in dhcp: + if 'certificate' in dhcp['failover']: + cert_name = dhcp['failover']['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) + + dhcp['failover']['cert_file'] = cert_file + dhcp['failover']['cert_key_file'] = cert_key_file + + if 'ca_certificate' in dhcp['failover']: + ca_cert_name = dhcp['failover']['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) + + dhcp['failover']['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) + + return None + +def apply(dhcp): + services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] + + if not dhcp or 'disable' in dhcp: + for service in services: + call(f'systemctl stop {service}.service') + + if os.path.exists(config_file): + os.unlink(config_file) + + return None + + for service in services: + action = 'restart' + + if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: + action = 'stop' + + if service == 'kea-ctrl-agent' and 'failover' not in dhcp: + action = 'stop' + + call(f'systemctl {action} {service}.service') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_dhcpv6-relay.py b/src/conf_mode/service_dhcpv6-relay.py new file mode 100755 index 000000000..6537ca3c2 --- /dev/null +++ b/src/conf_mode/service_dhcpv6-relay.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 . + +import os + +from sys import exit + +from vyos.config import Config +from vyos.ifconfig import Interface +from vyos.template import render +from vyos.template import is_ipv6 +from vyos.utils.process import call +from vyos.utils.network import is_ipv6_link_local +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = '/run/dhcp-relay/dhcrelay6.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dhcpv6-relay'] + if not conf.exists(base): + return None + + relay = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return relay + +def verify(relay): + # bail out early - looks like removal from running config + if not relay or 'disable' in relay: + return None + + if 'upstream_interface' not in relay: + raise ConfigError('At least one upstream interface required!') + for interface, config in relay['upstream_interface'].items(): + if 'address' not in config: + raise ConfigError('DHCPv6 server required for upstream ' \ + f'interface {interface}!') + + if 'listen_interface' not in relay: + raise ConfigError('At least one listen interface required!') + + # DHCPv6 relay requires at least one global unicat address assigned to the + # interface + for interface in relay['listen_interface']: + has_global = False + for addr in Interface(interface).get_addr(): + if is_ipv6(addr) and not is_ipv6_link_local(addr): + has_global = True + if not has_global: + raise ConfigError(f'Interface {interface} does not have global '\ + 'IPv6 address assigned!') + + return None + +def generate(relay): + # bail out early - looks like removal from running config + if not relay or 'disable' in relay: + return None + + render(config_file, 'dhcp-relay/dhcrelay6.conf.j2', relay) + return None + +def apply(relay): + # bail out early - looks like removal from running config + service_name = 'isc-dhcp-relay6.service' + if not relay or 'disable' in relay: + # DHCPv6 relay support is removed in the commit + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + return None + + call(f'systemctl restart {service_name}') + + return None + +if __name__ == '__main__': + 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_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py new file mode 100755 index 000000000..9cc57dbcf --- /dev/null +++ b/src/conf_mode/service_dhcpv6-server.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from ipaddress import ip_address +from ipaddress import ip_network +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.file import chmod_775 +from vyos.utils.file import makedir +from vyos.utils.file import write_file +from vyos.utils.dict import dict_search +from vyos.utils.network import is_subnet_connected +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = '/run/kea/kea-dhcp6.conf' +ctrl_socket = '/run/kea/dhcp6-ctrl-socket' +lease_file = '/config/dhcp/dhcp6-leases.csv' +user_group = '_kea' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dhcpv6-server'] + if not conf.exists(base): + return None + + dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + return dhcpv6 + +def verify(dhcpv6): + # bail out early - looks like removal from running config + if not dhcpv6 or 'disable' in dhcpv6: + return None + + # If DHCP is enabled we need one share-network + if 'shared_network_name' not in dhcpv6: + raise ConfigError('No DHCPv6 shared networks configured. At least '\ + 'one DHCPv6 shared network must be configured.') + + # Inspect shared-network/subnet + subnets = [] + listen_ok = False + for network, network_config in dhcpv6['shared_network_name'].items(): + # A shared-network requires a subnet definition + if 'subnet' not in network_config: + raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\ + 'At least one lease subnet must be configured for '\ + 'each shared network!') + + for subnet, subnet_config in network_config['subnet'].items(): + if 'address_range' in subnet_config: + if 'start' in subnet_config['address_range']: + range6_start = [] + range6_stop = [] + for start, start_config in subnet_config['address_range']['start'].items(): + if 'stop' not in start_config: + raise ConfigError(f'address-range stop address for start "{start}" is not defined!') + stop = start_config['stop'] + + # Start address must be inside network + if not ip_address(start) in ip_network(subnet): + raise ConfigError(f'address-range start address "{start}" is not in subnet "{subnet}"!') + + # Stop address must be inside network + if not ip_address(stop) in ip_network(subnet): + raise ConfigError(f'address-range stop address "{stop}" is not in subnet "{subnet}"!') + + # Stop address must be greater or equal to start address + if not ip_address(stop) >= ip_address(start): + raise ConfigError(f'address-range stop address "{stop}" must be greater then or equal ' \ + f'to the range start address "{start}"!') + + # DHCPv6 range start address must be unique - two ranges can't + # start with the same address - makes no sense + if start in range6_start: + raise ConfigError(f'Conflicting DHCPv6 lease range: '\ + f'Pool start address "{start}" defined multipe times!') + range6_start.append(start) + + # DHCPv6 range stop address must be unique - two ranges can't + # end with the same address - makes no sense + if stop in range6_stop: + raise ConfigError(f'Conflicting DHCPv6 lease range: '\ + f'Pool stop address "{stop}" defined multipe times!') + range6_stop.append(stop) + + if 'prefix' in subnet_config: + for prefix in subnet_config['prefix']: + if ip_network(prefix) not in ip_network(subnet): + raise ConfigError(f'address-range prefix "{prefix}" is not in subnet "{subnet}""') + + # Prefix delegation sanity checks + if 'prefix_delegation' in subnet_config: + if 'prefix' not in subnet_config['prefix_delegation']: + raise ConfigError('prefix-delegation prefix not defined!') + + for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items(): + if 'delegated_length' not in prefix_config: + raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\ + f'must be configured') + + if 'prefix_length' not in prefix_config: + raise ConfigError('Length of delegated IPv6 prefix must be configured') + + if prefix_config['prefix_length'] > prefix_config['delegated_length']: + raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') + + # Static mappings don't require anything (but check if IP is in subnet if it's set) + if 'static_mapping' in subnet_config: + for mapping, mapping_config in subnet_config['static_mapping'].items(): + if 'ipv6_address' in mapping_config: + # Static address must be in subnet + if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): + raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') + + 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 'vendor_option' in subnet_config: + if len(dict_search('vendor_option.cisco.tftp_server', subnet_config)) > 2: + raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') + + # Subnets must be unique + if subnet in subnets: + raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') + subnets.append(subnet) + + # DHCPv6 requires at least one configured address range or one static mapping + # (FIXME: is not actually checked right now?) + + # There must be one subnet connected to a listen interface if network is not disabled. + if 'disable' not in network_config: + if is_subnet_connected(subnet): + listen_ok = True + + # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping + # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" + net = ip_network(subnet) + for n in subnets: + net2 = ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ + 'this machine. At least one subnet6 must be connected such that '\ + 'DHCPv6 listens on an interface!') + + + return None + +def generate(dhcpv6): + # bail out early - looks like removal from running config + if not dhcpv6 or 'disable' in dhcpv6: + return None + + dhcpv6['lease_file'] = lease_file + dhcpv6['machine'] = os.uname().machine + + # Create directory for lease file if necessary + lease_dir = os.path.dirname(lease_file) + if not os.path.isdir(lease_dir): + makedir(lease_dir, group='vyattacfg') + chmod_775(lease_dir) + + # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way + if not os.path.exists(lease_file): + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) + + render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group) + return None + +def apply(dhcpv6): + # bail out early - looks like removal from running config + service_name = 'kea-dhcp6-server.service' + if not dhcpv6 or 'disable' in dhcpv6: + # DHCP server is removed in the commit + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + return None + + call(f'systemctl restart {service_name}') + + 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_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py new file mode 100755 index 000000000..99fa8feee --- /dev/null +++ b/src/conf_mode/service_dns_dynamic.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import re +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/ddclient/ddclient.conf' +systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf' + +# Dynamic interfaces that might not exist when the configuration is loaded +dynamic_interfaces = ('pppoe', 'sstpc') + +# Protocols that require zone +zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi', + 'nfsn', 'nsupdate'] +zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1'] + +# Protocols that do not require username +username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2', + 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla', + 'nsupdate', 'regfishde'] + +# Protocols that support TTL +ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn', + 'nsupdate'] + +# Protocols that support both IPv4 and IPv6 +dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns', + 'dyndns2', 'easydns', 'freedns', 'hetzner', 'infomaniak', + 'njalla'] + +# dyndns2 protocol in ddclient honors dual stack for selective servers +# because of the way it is implemented in ddclient +dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com'] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'dns', 'dynamic'] + if not conf.exists(base): + return None + + dyndns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + dyndns['config_file'] = config_file + return dyndns + +def verify(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + return None + + # Dynamic DNS service provider - configuration validation + for service, config in dyndns['name'].items(): + + error_msg_req = f'is required for Dynamic DNS service "{service}"' + error_msg_uns = f'is not supported for Dynamic DNS service "{service}"' + + for field in ['protocol', 'address', 'host_name']: + if field not in config: + raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}') + + # If dyndns address is an interface, ensure + # that the interface exists (or just warn if dynamic interface) + # and that web-options are not set + if config['address'] != 'web': + # exclude check interface for dynamic interfaces + if config['address'].startswith(dynamic_interfaces): + Warning(f'Interface "{config["address"]}" does not exist yet and cannot ' + f'be used for Dynamic DNS service "{service}" until it is up!') + else: + verify_interface_exists(config['address']) + if 'web_options' in config: + raise ConfigError(f'"web-options" is applicable only when using HTTP(S) ' + f'web request to obtain the IP address') + + # Warn if using checkip.dyndns.org, as it does not support HTTPS + # See: https://github.com/ddclient/ddclient/issues/597 + if 'web_options' in config: + if 'url' not in config['web_options']: + raise ConfigError(f'"url" in "web-options" {error_msg_req} ' + f'with protocol "{config["protocol"]}"') + elif re.search("^(https?://)?checkip\.dyndns\.org", config['web_options']['url']): + Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address ' + f'lookup. Please use a different IP address lookup service.') + + # RFC2136 uses 'key' instead of 'password' + if config['protocol'] != 'nsupdate' and 'password' not in config: + raise ConfigError(f'"password" {error_msg_req}') + + # Other RFC2136 specific configuration validation + if config['protocol'] == 'nsupdate': + if 'password' in config: + raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"') + for field in ['server', 'key']: + if field not in config: + raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] in zone_necessary and 'zone' not in config: + raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] not in zone_supported and 'zone' in config: + raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"') + + if config['protocol'] not in username_unnecessary and 'username' not in config: + raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"') + + if config['protocol'] not in ttl_supported and 'ttl' in config: + raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"') + + if config['ip_version'] == 'both': + if config['protocol'] not in dualstack_supported: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' + f'with protocol "{config["protocol"]}"') + # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org) + if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers: + raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} ' + f'for "{config["server"]}" with protocol "{config["protocol"]}"') + + if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']): + raise ConfigError(f'"expiry-time" must be greater than "wait-time" for ' + f'Dynamic DNS service "{service}"') + + return None + +def generate(dyndns): + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + return None + + render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600) + render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns) + return None + +def apply(dyndns): + systemd_service = 'ddclient.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + + # bail out early - looks like removal from running config + if not dyndns or 'name' not in dyndns: + call(f'systemctl stop {systemd_service}') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call(f'systemctl reload-or-restart {systemd_service}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py new file mode 100755 index 000000000..c186f47af --- /dev/null +++ b/src/conf_mode/service_dns_forwarding.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 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 . + +import os + +from netifaces import interfaces +from sys import exit +from glob import glob + +from vyos.config import Config +from vyos.hostsd_client import Client as hostsd_client +from vyos.template import render +from vyos.template import bracketize_ipv6 +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos.utils.dict import dict_search + +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +pdns_rec_user = pdns_rec_group = 'pdns' +pdns_rec_run_dir = '/run/powerdns' +pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua' +pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua' +pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf' +pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf' + +hostsd_tag = 'static' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'dns', 'forwarding'] + if not conf.exists(base): + return None + + dns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + # some additions to the default dictionary + if 'system' in dns: + base_nameservers = ['system', 'name-server'] + if conf.exists(base_nameservers): + dns.update({'system_name_server': conf.return_values(base_nameservers)}) + + if 'authoritative_domain' in dns: + dns['authoritative_zones'] = [] + dns['authoritative_zone_errors'] = [] + for node in dns['authoritative_domain']: + zonedata = dns['authoritative_domain'][node] + if ('disable' in zonedata) or (not 'records' in zonedata): + continue + zone = { + 'name': node, + 'file': "{}/zone.{}.conf".format(pdns_rec_run_dir, node), + 'records': [], + } + + recorddata = zonedata['records'] + + for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ns', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]: + if rtype not in recorddata: + continue + for subnode in recorddata[rtype]: + if 'disable' in recorddata[rtype][subnode]: + continue + + rdata = recorddata[rtype][subnode] + + if rtype in [ 'a', 'aaaa' ]: + if not 'address' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one address is required') + continue + + if subnode == 'any': + subnode = '*' + + for address in rdata['address']: + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': address + }) + elif rtype in ['cname', 'ptr', 'ns']: + if not 'target' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: target is required') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{}.'.format(rdata['target']) + }) + elif rtype == 'mx': + if not 'server' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one server is required') + continue + + for servername in rdata['server']: + serverdata = rdata['server'][servername] + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {}.'.format(serverdata['priority'], servername) + }) + elif rtype == 'txt': + if not 'value' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one value is required') + continue + + for value in rdata['value']: + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': "\"{}\"".format(value.replace("\"", "\\\"")) + }) + elif rtype == 'spf': + if not 'value' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: value is required') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '"{}"'.format(rdata['value'].replace("\"", "\\\"")) + }) + elif rtype == 'srv': + if not 'entry' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one entry is required') + continue + + for entryno in rdata['entry']: + entrydata = rdata['entry'][entryno] + if not 'hostname' in entrydata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: hostname is required for entry {entryno}') + continue + + if not 'port' in entrydata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: port is required for entry {entryno}') + continue + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {} {} {}.'.format(entrydata['priority'], entrydata['weight'], entrydata['port'], entrydata['hostname']) + }) + elif rtype == 'naptr': + if not 'rule' in rdata: + dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one rule is required') + continue + + for ruleno in rdata['rule']: + ruledata = rdata['rule'][ruleno] + flags = "" + if 'lookup-srv' in ruledata: + flags += "S" + if 'lookup-a' in ruledata: + flags += "A" + if 'resolve-uri' in ruledata: + flags += "U" + if 'protocol-specific' in ruledata: + flags += "P" + + if 'order' in ruledata: + order = ruledata['order'] + else: + order = ruleno + + if 'regexp' in ruledata: + regexp= ruledata['regexp'].replace("\"", "\\\"") + else: + regexp = '' + + if ruledata['replacement']: + replacement = '{}.'.format(ruledata['replacement']) + else: + replacement = '' + + zone['records'].append({ + 'name': subnode, + 'type': rtype.upper(), + 'ttl': rdata['ttl'], + 'value': '{} {} "{}" "{}" "{}" {}'.format(order, ruledata['preference'], flags, ruledata['service'], regexp, replacement) + }) + + dns['authoritative_zones'].append(zone) + + return dns + +def verify(dns): + # bail out early - looks like removal from running config + if not dns: + return None + + if 'listen_address' not in dns: + raise ConfigError('DNS forwarding requires a listen-address') + + if 'allow_from' not in dns: + raise ConfigError('DNS forwarding requires an allow-from network') + + # we can not use dict_search() when testing for domain servers + # as a domain will contains dot's which is out dictionary delimiter. + if 'domain' in dns: + for domain in dns['domain']: + if 'name_server' not in dns['domain'][domain]: + raise ConfigError(f'No server configured for domain {domain}!') + + if 'dns64_prefix' in dns: + dns_prefix = dns['dns64_prefix'].split('/')[1] + # RFC 6147 requires prefix /96 + if int(dns_prefix) != 96: + raise ConfigError('DNS 6to4 prefix must be of length /96') + + if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']: + for error in dns['authoritative_zone_errors']: + print(error) + raise ConfigError('Invalid authoritative records have been defined') + + if 'system' in dns: + if not 'system_name_server' in dns: + print('Warning: No "system name-server" configured') + + return None + +def generate(dns): + # bail out early - looks like removal from running config + if not dns: + return None + + render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', + dns, user=pdns_rec_user, group=pdns_rec_group) + + render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', + dns, user=pdns_rec_user, group=pdns_rec_group) + + for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): + os.unlink(zone_filename) + + if 'authoritative_zones' in dns: + for zone in dns['authoritative_zones']: + render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2', + zone, user=pdns_rec_user, group=pdns_rec_group) + + + # if vyos-hostsd didn't create its files yet, create them (empty) + for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]: + with open(file, 'a'): + pass + chown(file, user=pdns_rec_user, group=pdns_rec_group) + + return None + +def apply(dns): + if not dns: + # DNS forwarding is removed in the commit + call('systemctl stop pdns-recursor.service') + + if os.path.isfile(pdns_rec_config_file): + os.unlink(pdns_rec_config_file) + + for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'): + os.unlink(zone_filename) + else: + ### first apply vyos-hostsd config + hc = hostsd_client() + + # add static nameservers to hostsd so they can be joined with other + # sources + hc.delete_name_servers([hostsd_tag]) + if 'name_server' in dns: + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + nslist = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in dns['name_server'].items()] + hc.add_name_servers({hostsd_tag: nslist}) + + # delete all nameserver tags + hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor()) + + ## add nameserver tags - the order determines the nameserver order! + # our own tag (static) + hc.add_name_server_tags_recursor([hostsd_tag]) + + if 'system' in dns: + hc.add_name_server_tags_recursor(['system']) + else: + hc.delete_name_server_tags_recursor(['system']) + + # add dhcp nameserver tags for configured interfaces + if 'system_name_server' in dns: + for interface in dns['system_name_server']: + # system_name_server key contains both IP addresses and interface + # names (DHCP) to use DNS servers. We need to check if the + # value is an interface name - only if this is the case, add the + # interface based DNS forwarder. + if interface in interfaces(): + 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())) + if 'domain' in dns: + zones = dns['domain'] + for domain in zones.keys(): + # 'name_server' is of the form + # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...} + # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...] + zones[domain]['name_server'] = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p) + for (h, p) in zones[domain]['name_server'].items()] + hc.add_forward_zones(zones) + + # hostsd generates NTAs for the authoritative zones + # the list and keys() are required as get returns a dict, not list + hc.delete_authoritative_zones(list(hc.get_authoritative_zones())) + if 'authoritative_zones' in dns: + hc.add_authoritative_zones(list(map(lambda zone: zone['name'], dns['authoritative_zones']))) + + # call hostsd to generate forward-zones and its lua-config-file + hc.apply() + + ### finally (re)start pdns-recursor + call('systemctl restart pdns-recursor.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_event-handler.py b/src/conf_mode/service_event-handler.py new file mode 100755 index 000000000..5028ef52f --- /dev/null +++ b/src/conf_mode/service_event-handler.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 . + +import json +from pathlib import Path + +from vyos.config import Config +from vyos.utils.dict import dict_search +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +service_name = 'vyos-event-handler' +service_conf = Path(f'/run/{service_name}.conf') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'event-handler', 'event'] + config = conf.get_config_dict(base, + get_first_key=True, + no_tag_node_value_mangle=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if not config: + return None + + for name, event_config in config.items(): + if not dict_search('filter.pattern', event_config) or not dict_search( + 'script.path', event_config): + raise ConfigError( + 'Event-handler: both pattern and script path items are mandatory' + ) + + if dict_search('script.environment.message', event_config): + raise ConfigError( + 'Event-handler: "message" environment variable is reserved for log message text' + ) + + +def generate(config): + if not config: + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + return None + + +def apply(config): + if config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.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_event_handler.py b/src/conf_mode/service_event_handler.py deleted file mode 100755 index 5028ef52f..000000000 --- a/src/conf_mode/service_event_handler.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2022 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 . - -import json -from pathlib import Path - -from vyos.config import Config -from vyos.utils.dict import dict_search -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag - -airbag.enable() - -service_name = 'vyos-event-handler' -service_conf = Path(f'/run/{service_name}.conf') - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'event-handler', 'event'] - config = conf.get_config_dict(base, - get_first_key=True, - no_tag_node_value_mangle=True) - - return config - - -def verify(config): - # bail out early - looks like removal from running config - if not config: - return None - - for name, event_config in config.items(): - if not dict_search('filter.pattern', event_config) or not dict_search( - 'script.path', event_config): - raise ConfigError( - 'Event-handler: both pattern and script path items are mandatory' - ) - - if dict_search('script.environment.message', event_config): - raise ConfigError( - 'Event-handler: "message" environment variable is reserved for log message text' - ) - - -def generate(config): - if not config: - # Remove old config and return - service_conf.unlink(missing_ok=True) - return None - - # Write configuration file - conf_json = json.dumps(config, indent=4) - service_conf.write_text(conf_json) - - return None - - -def apply(config): - if config: - call(f'systemctl restart {service_name}.service') - else: - call(f'systemctl stop {service_name}.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_https.py b/src/conf_mode/service_https.py new file mode 100755 index 000000000..3dc5dfc01 --- /dev/null +++ b/src/conf_mode/service_https.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os +import sys +import json + +from copy import deepcopy +from time import sleep + +import vyos.defaults +import vyos.certbot_util + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdiff import get_config_diff +from vyos.configverify import verify_vrf +from vyos import ConfigError +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running +from vyos.utils.process import is_systemd_service_active +from vyos.utils.network import check_port_availability +from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.file import write_file + +from vyos import airbag +airbag.enable() + +config_file = '/etc/nginx/sites-available/default' +systemd_override = r'/run/systemd/system/nginx.service.d/override.conf' +cert_dir = '/etc/ssl/certs' +key_dir = '/etc/ssl/private' +certbot_dir = vyos.defaults.directories['certbot'] + +api_config_state = '/run/http-api-state' +systemd_service = '/run/systemd/system/vyos-http-api.service' + +# https config needs to coordinate several subsystems: api, certbot, +# self-signed certificate, as well as the virtual hosts defined within the +# https config definition itself. Consequently, one needs a general dict, +# encompassing the https and other configs, and a list of such virtual hosts +# (server blocks in nginx terminology) to pass to the jinja2 template. +default_server_block = { + 'id' : '', + 'address' : '*', + 'port' : '443', + 'name' : ['_'], + 'api' : False, + 'vyos_cert' : {}, + 'certbot' : False +} + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'https'] + if not conf.exists(base): + return None + + diff = get_config_diff(conf) + + https = conf.get_config_dict(base, get_first_key=True) + + if https: + https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + + https['children_changed'] = diff.node_changed_children(base) + https['api_add_or_delete'] = diff.node_changed_presence(base + ['api']) + + if 'api' not in https: + return https + + http_api = conf.get_config_dict(base + ['api'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + if http_api.from_defaults(['graphql']): + del http_api['graphql'] + + # Do we run inside a VRF context? + vrf_path = ['service', 'https', 'vrf'] + if conf.exists(vrf_path): + http_api['vrf'] = conf.return_value(vrf_path) + + https['api'] = http_api + + return https + +def verify(https): + from vyos.utils.dict import dict_search + + if https is None: + return None + + if 'certificates' in https: + certificates = https['certificates'] + + if 'certificate' in certificates: + if not https['pki']: + raise ConfigError("PKI is not configured") + + cert_name = certificates['certificate'] + + if cert_name not in https['pki']['certificate']: + raise ConfigError("Invalid certificate on https configuration") + + pki_cert = https['pki']['certificate'][cert_name] + + if 'certificate' not in pki_cert: + raise ConfigError("Missing certificate on https configuration") + + if 'private' not in pki_cert or 'key' not in pki_cert['private']: + raise ConfigError("Missing certificate private key on https configuration") + + if 'certbot' in https['certificates']: + vhost_names = [] + for _, vh_conf in https.get('virtual-host', {}).items(): + vhost_names += vh_conf.get('server-name', []) + domains = https['certificates']['certbot'].get('domain-name', []) + domains_found = [domain for domain in domains if domain in vhost_names] + if not domains_found: + raise ConfigError("At least one 'virtual-host server-name' " + "matching the 'certbot domain-name' is required.") + + server_block_list = [] + + # organize by vhosts + vhost_dict = https.get('virtual-host', {}) + + if not vhost_dict: + # no specified virtual hosts (server blocks); use default + server_block_list.append(default_server_block) + else: + for vhost in list(vhost_dict): + server_block = deepcopy(default_server_block) + data = vhost_dict.get(vhost, {}) + server_block['address'] = data.get('listen-address', '*') + server_block['port'] = data.get('port', '443') + server_block_list.append(server_block) + + for entry in server_block_list: + _address = entry.get('address') + _address = '0.0.0.0' if _address == '*' else _address + _port = entry.get('port') + proto = 'tcp' + if check_port_availability(_address, int(_port), proto) is not True and \ + not is_listen_port_bind_service(int(_port), 'nginx'): + raise ConfigError(f'"{proto}" port "{_port}" is used by another service') + + verify_vrf(https) + + # Verify API server settings, if present + if 'api' in https: + keys = dict_search('api.keys.id', https) + gql_auth_type = dict_search('api.graphql.authentication.type', https) + + # If "api graphql" is not defined and `gql_auth_type` is None, + # there's certainly no JWT auth option, and keys are required + jwt_auth = (gql_auth_type == "token") + + # Check for incomplete key configurations in every case + valid_keys_exist = False + if keys: + for k in keys: + if 'key' not in keys[k]: + raise ConfigError(f'Missing HTTPS API key string for key id "{k}"') + else: + valid_keys_exist = True + + # If only key-based methods are enabled, + # fail the commit if no valid key configurations are found + if (not valid_keys_exist) and (not jwt_auth): + raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled') + + if (not valid_keys_exist) and jwt_auth: + Warning(f'API keys are not configured: the classic (non-GraphQL) API will be unavailable.') + + return None + +def generate(https): + if https is None: + return None + + if 'api' not in https: + if os.path.exists(systemd_service): + os.unlink(systemd_service) + else: + render(systemd_service, 'https/vyos-http-api.service.j2', https['api']) + with open(api_config_state, 'w') as f: + json.dump(https['api'], f, indent=2) + + server_block_list = [] + + # organize by vhosts + + vhost_dict = https.get('virtual-host', {}) + + if not vhost_dict: + # no specified virtual hosts (server blocks); use default + server_block_list.append(default_server_block) + else: + for vhost in list(vhost_dict): + server_block = deepcopy(default_server_block) + server_block['id'] = vhost + data = vhost_dict.get(vhost, {}) + server_block['address'] = data.get('listen-address', '*') + server_block['port'] = data.get('port', '443') + name = data.get('server-name', ['_']) + server_block['name'] = name + allow_client = data.get('allow-client', {}) + server_block['allow_client'] = allow_client.get('address', []) + server_block_list.append(server_block) + + # get certificate data + + cert_dict = https.get('certificates', {}) + + if 'certificate' in cert_dict: + cert_name = cert_dict['certificate'] + pki_cert = https['pki']['certificate'][cert_name] + + cert_path = os.path.join(cert_dir, f'{cert_name}.pem') + key_path = os.path.join(key_dir, f'{cert_name}.pem') + + server_cert = str(wrap_certificate(pki_cert['certificate'])) + if 'ca-certificate' in cert_dict: + ca_cert = cert_dict['ca-certificate'] + server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate'])) + + write_file(cert_path, server_cert) + write_file(key_path, wrap_private_key(pki_cert['private']['key'])) + + vyos_cert_data = { + 'crt': cert_path, + 'key': key_path + } + + for block in server_block_list: + block['vyos_cert'] = vyos_cert_data + + # letsencrypt certificate using certbot + + certbot = False + cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) + if cert_domains: + certbot = True + for domain in cert_domains: + sub_list = vyos.certbot_util.choose_server_block(server_block_list, + domain) + if sub_list: + for sb in sub_list: + sb['certbot'] = True + sb['certbot_dir'] = certbot_dir + # certbot organizes certificates by first domain + sb['certbot_domain_dir'] = cert_domains[0] + + if 'api' in list(https): + vhost_list = https.get('api-restrict', {}).get('virtual-host', []) + if not vhost_list: + for block in server_block_list: + block['api'] = True + else: + for block in server_block_list: + if block['id'] in vhost_list: + block['api'] = True + + data = { + 'server_block_list': server_block_list, + 'certbot': certbot + } + + render(config_file, 'https/nginx.default.j2', data) + render(systemd_override, 'https/override.conf.j2', https) + return None + +def apply(https): + # Reload systemd manager configuration + call('systemctl daemon-reload') + http_api_service_name = 'vyos-http-api.service' + https_service_name = 'nginx.service' + + if https is None: + if is_systemd_service_active(f'{http_api_service_name}'): + call(f'systemctl stop {http_api_service_name}') + call(f'systemctl stop {https_service_name}') + return + + if 'api' in https['children_changed']: + if 'api' in https: + if is_systemd_service_running(f'{http_api_service_name}'): + call(f'systemctl reload {http_api_service_name}') + else: + call(f'systemctl restart {http_api_service_name}') + # Let uvicorn settle before (possibly) restarting nginx + sleep(1) + else: + if is_systemd_service_active(f'{http_api_service_name}'): + call(f'systemctl stop {http_api_service_name}') + + if (not is_systemd_service_running(f'{https_service_name}') or + https['api_add_or_delete'] or + set(https['children_changed']) - set(['api'])): + call(f'systemctl restart {https_service_name}') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/service_https_certificates_certbot.py b/src/conf_mode/service_https_certificates_certbot.py new file mode 100755 index 000000000..1a6a498de --- /dev/null +++ b/src/conf_mode/service_https_certificates_certbot.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-2020 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 . + +import sys +import os + +import vyos.defaults +from vyos.config import Config +from vyos import ConfigError +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running + +from vyos import airbag +airbag.enable() + +vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] +vyos_certbot_dir = vyos.defaults.directories['certbot'] + +dependencies = [ + 'service_https.py', +] + +def request_certbot(cert): + email = cert.get('email') + if email is not None: + email_flag = '-m {0}'.format(email) + else: + email_flag = '' + + domains = cert.get('domains') + if domains is not None: + domain_flag = '-d ' + ' -d '.join(domains) + else: + domain_flag = '' + + certbot_cmd = f'certbot certonly --config-dir {vyos_certbot_dir} -n --nginx --agree-tos --no-eff-email --expand {email_flag} {domain_flag}' + + cmd(certbot_cmd, + raising=ConfigError, + message="The certbot request failed for the specified domains.") + +def get_config(): + conf = Config() + if not conf.exists('service https certificates certbot'): + return None + else: + conf.set_level('service https certificates certbot') + + cert = {} + + if conf.exists('domain-name'): + cert['domains'] = conf.return_values('domain-name') + + if conf.exists('email'): + cert['email'] = conf.return_value('email') + + return cert + +def verify(cert): + if cert is None: + return None + + if 'domains' not in cert: + raise ConfigError("At least one domain name is required to" + " request a letsencrypt certificate.") + + if 'email' not in cert: + raise ConfigError("An email address is required to request" + " a letsencrypt certificate.") + +def generate(cert): + if cert is None: + return None + + # certbot will attempt to reload nginx, even with 'certonly'; + # start nginx if not active + if not is_systemd_service_running('nginx.service'): + call('systemctl start nginx.service') + + request_certbot(cert) + +def apply(cert): + if cert is not None: + call('systemctl restart certbot.timer') + else: + call('systemctl stop certbot.timer') + return None + + for dep in dependencies: + cmd(f'{vyos_conf_scripts_dir}/{dep}', raising=ConfigError) + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/service_ids_ddos-protection.py b/src/conf_mode/service_ids_ddos-protection.py new file mode 100755 index 000000000..276a71fcb --- /dev/null +++ b/src/conf_mode/service_ids_ddos-protection.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/fastnetmon/fastnetmon.conf' +networks_list = r'/run/fastnetmon/networks_list' +excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' +attack_dir = '/var/log/fastnetmon_attacks' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'ids', 'ddos-protection'] + if not conf.exists(base): + return None + + fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return fastnetmon + +def verify(fastnetmon): + if not fastnetmon: + return None + + if 'mode' not in fastnetmon: + raise ConfigError('Specify operating mode!') + + if fastnetmon.get('mode') == 'mirror' and 'listen_interface' not in fastnetmon: + raise ConfigError("Incorrect settings for 'mode mirror': must specify interface(s) for traffic mirroring") + + if fastnetmon.get('mode') == 'sflow' and 'listen_address' not in fastnetmon.get('sflow', {}): + raise ConfigError("Incorrect settings for 'mode sflow': must specify sFlow 'listen-address'") + + if 'alert_script' in fastnetmon: + if os.path.isfile(fastnetmon['alert_script']): + # Check script permissions + if not os.access(fastnetmon['alert_script'], os.X_OK): + raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) + else: + raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) + +def generate(fastnetmon): + if not fastnetmon: + for file in [config_file, networks_list]: + if os.path.isfile(file): + os.unlink(file) + + return None + + # Create dir for log attack details + if not os.path.exists(attack_dir): + os.mkdir(attack_dir) + + render(config_file, 'ids/fastnetmon.j2', fastnetmon) + render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) + render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) + return None + +def apply(fastnetmon): + systemd_service = 'fastnetmon.service' + if not fastnetmon: + # Stop fastnetmon service if removed + call(f'systemctl stop {systemd_service}') + else: + call(f'systemctl reload-or-restart {systemd_service}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py deleted file mode 100755 index 276a71fcb..000000000 --- a/src/conf_mode/service_ids_fastnetmon.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from sys import exit - -from vyos.config import Config -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/fastnetmon/fastnetmon.conf' -networks_list = r'/run/fastnetmon/networks_list' -excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' -attack_dir = '/var/log/fastnetmon_attacks' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'ids', 'ddos-protection'] - if not conf.exists(base): - return None - - fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return fastnetmon - -def verify(fastnetmon): - if not fastnetmon: - return None - - if 'mode' not in fastnetmon: - raise ConfigError('Specify operating mode!') - - if fastnetmon.get('mode') == 'mirror' and 'listen_interface' not in fastnetmon: - raise ConfigError("Incorrect settings for 'mode mirror': must specify interface(s) for traffic mirroring") - - if fastnetmon.get('mode') == 'sflow' and 'listen_address' not in fastnetmon.get('sflow', {}): - raise ConfigError("Incorrect settings for 'mode sflow': must specify sFlow 'listen-address'") - - if 'alert_script' in fastnetmon: - if os.path.isfile(fastnetmon['alert_script']): - # Check script permissions - if not os.access(fastnetmon['alert_script'], os.X_OK): - raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) - else: - raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) - -def generate(fastnetmon): - if not fastnetmon: - for file in [config_file, networks_list]: - if os.path.isfile(file): - os.unlink(file) - - return None - - # Create dir for log attack details - if not os.path.exists(attack_dir): - os.mkdir(attack_dir) - - render(config_file, 'ids/fastnetmon.j2', fastnetmon) - render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) - render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) - return None - -def apply(fastnetmon): - systemd_service = 'fastnetmon.service' - if not fastnetmon: - # Stop fastnetmon service if removed - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/service_lldp.py b/src/conf_mode/service_lldp.py new file mode 100755 index 000000000..3c647a0e8 --- /dev/null +++ b/src/conf_mode/service_lldp.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-2022 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 . + +import os + +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.utils.network import is_addr_assigned +from vyos.utils.network import is_loopback_addr +from vyos.version import get_version_data +from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = "/etc/default/lldpd" +vyos_config_file = "/etc/lldpd.d/01-vyos.conf" +base = ['service', 'lldp'] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + if not conf.exists(base): + return {} + + lldp = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + if conf.exists(['service', 'snmp']): + lldp['system_snmp_enabled'] = '' + + version_data = get_version_data() + lldp['version'] = version_data['version'] + + # prune location information if not set by user + for interface in lldp.get('interface', []): + if lldp.from_defaults(['interface', interface, 'location']): + del lldp['interface'][interface]['location'] + elif lldp.from_defaults(['interface', interface, 'location','coordinate_based']): + del lldp['interface'][interface]['location']['coordinate_based'] + + return lldp + +def verify(lldp): + # bail out early - looks like removal from running config + if lldp is None: + return + + if 'management_address' in lldp: + for address in lldp['management_address']: + message = f'LLDP management address "{address}" is invalid' + if is_loopback_addr(address): + Warning(f'{message} - loopback address') + elif not is_addr_assigned(address): + Warning(f'{message} - not assigned to any interface') + + if 'interface' in lldp: + for interface, interface_config in lldp['interface'].items(): + # bail out early if no location info present in interface config + if 'location' not in interface_config: + continue + if 'coordinate_based' in interface_config['location']: + if not {'latitude', 'latitude'} <= set(interface_config['location']['coordinate_based']): + raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!') + + # check options + if 'snmp' in lldp: + if 'system_snmp_enabled' not in lldp: + raise ConfigError('SNMP must be configured to enable LLDP SNMP!') + + +def generate(lldp): + # bail out early - looks like removal from running config + if lldp is None: + return + + render(config_file, 'lldp/lldpd.j2', lldp) + render(vyos_config_file, 'lldp/vyos.conf.j2', lldp) + +def apply(lldp): + systemd_service = 'lldpd.service' + if lldp: + # start/restart lldp service + call(f'systemctl restart {systemd_service}') + else: + # LLDP service has been terminated + call(f'systemctl stop {systemd_service}') + if os.path.isfile(config_file): + os.unlink(config_file) + if os.path.isfile(vyos_config_file): + os.unlink(vyos_config_file) + +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_mdns-repeater.py b/src/conf_mode/service_mdns-repeater.py deleted file mode 100755 index 6526c23d1..000000000 --- a/src/conf_mode/service_mdns-repeater.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2017-2022 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 . - -import os - -from json import loads -from sys import exit -from netifaces import ifaddresses, interfaces, AF_INET, AF_INET6 - -from vyos.config import Config -from vyos.ifconfig.vrrp import VRRP -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = '/run/avahi-daemon/avahi-daemon.conf' -systemd_override = r'/run/systemd/system/avahi-daemon.service.d/override.conf' -vrrp_running_file = '/run/mdns_vrrp_active' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'mdns', 'repeater'] - if not conf.exists(base): - return None - - mdns = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - if mdns: - mdns['vrrp_exists'] = conf.exists('high-availability vrrp') - mdns['config_file'] = config_file - - return mdns - -def verify(mdns): - if not mdns or 'disable' in mdns: - return None - - # We need at least two interfaces to repeat mDNS advertisments - if 'interface' not in mdns or len(mdns['interface']) < 2: - raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') - - # For mdns-repeater to work it is essential that the interfaces has - # an IPv4 address assigned - for interface in mdns['interface']: - if interface not in interfaces(): - raise ConfigError(f'Interface "{interface}" does not exist!') - - if mdns['ip_version'] in ['ipv4', 'both'] and AF_INET not in ifaddresses(interface): - raise ConfigError('mDNS repeater requires an IPv4 address to be ' - f'configured on interface "{interface}"') - - if mdns['ip_version'] in ['ipv6', 'both'] and AF_INET6 not in ifaddresses(interface): - raise ConfigError('mDNS repeater requires an IPv6 address to be ' - f'configured on interface "{interface}"') - - return None - -# Get VRRP states from interfaces, returns only interfaces where state is MASTER -def get_vrrp_master(interfaces): - json_data = loads(VRRP.collect('json')) - for group in json_data: - if 'data' in group: - if 'ifp_ifname' in group['data']: - iface = group['data']['ifp_ifname'] - state = group['data']['state'] # 2 = Master - if iface in interfaces and state != 2: - interfaces.remove(iface) - return interfaces - -def generate(mdns): - if not mdns: - return None - - if 'disable' in mdns: - print('Warning: mDNS repeater will be deactivated because it is disabled') - return None - - if mdns['vrrp_exists'] and 'vrrp_disable' in mdns: - mdns['interface'] = get_vrrp_master(mdns['interface']) - - if len(mdns['interface']) < 2: - return None - - render(config_file, 'mdns-repeater/avahi-daemon.conf.j2', mdns) - render(systemd_override, 'mdns-repeater/override.conf.j2', mdns) - return None - -def apply(mdns): - systemd_service = 'avahi-daemon.service' - # Reload systemd manager configuration - call('systemctl daemon-reload') - - if not mdns or 'disable' in mdns: - call(f'systemctl stop {systemd_service}') - if os.path.exists(config_file): - os.unlink(config_file) - - if os.path.exists(vrrp_running_file): - os.unlink(vrrp_running_file) - else: - if 'vrrp_disable' not in mdns and os.path.exists(vrrp_running_file): - os.unlink(vrrp_running_file) - - if mdns['vrrp_exists'] and 'vrrp_disable' in mdns: - if not os.path.exists(vrrp_running_file): - os.mknod(vrrp_running_file) # vrrp script looks for this file to update mdns repeater - - if len(mdns['interface']) < 2: - call(f'systemctl stop {systemd_service}') - return None - - call(f'systemctl restart {systemd_service}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/service_mdns_repeater.py b/src/conf_mode/service_mdns_repeater.py new file mode 100755 index 000000000..6526c23d1 --- /dev/null +++ b/src/conf_mode/service_mdns_repeater.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017-2022 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 . + +import os + +from json import loads +from sys import exit +from netifaces import ifaddresses, interfaces, AF_INET, AF_INET6 + +from vyos.config import Config +from vyos.ifconfig.vrrp import VRRP +from vyos.template import render +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = '/run/avahi-daemon/avahi-daemon.conf' +systemd_override = r'/run/systemd/system/avahi-daemon.service.d/override.conf' +vrrp_running_file = '/run/mdns_vrrp_active' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'mdns', 'repeater'] + if not conf.exists(base): + return None + + mdns = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + if mdns: + mdns['vrrp_exists'] = conf.exists('high-availability vrrp') + mdns['config_file'] = config_file + + return mdns + +def verify(mdns): + if not mdns or 'disable' in mdns: + return None + + # We need at least two interfaces to repeat mDNS advertisments + if 'interface' not in mdns or len(mdns['interface']) < 2: + raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') + + # For mdns-repeater to work it is essential that the interfaces has + # an IPv4 address assigned + for interface in mdns['interface']: + if interface not in interfaces(): + raise ConfigError(f'Interface "{interface}" does not exist!') + + if mdns['ip_version'] in ['ipv4', 'both'] and AF_INET not in ifaddresses(interface): + raise ConfigError('mDNS repeater requires an IPv4 address to be ' + f'configured on interface "{interface}"') + + if mdns['ip_version'] in ['ipv6', 'both'] and AF_INET6 not in ifaddresses(interface): + raise ConfigError('mDNS repeater requires an IPv6 address to be ' + f'configured on interface "{interface}"') + + return None + +# Get VRRP states from interfaces, returns only interfaces where state is MASTER +def get_vrrp_master(interfaces): + json_data = loads(VRRP.collect('json')) + for group in json_data: + if 'data' in group: + if 'ifp_ifname' in group['data']: + iface = group['data']['ifp_ifname'] + state = group['data']['state'] # 2 = Master + if iface in interfaces and state != 2: + interfaces.remove(iface) + return interfaces + +def generate(mdns): + if not mdns: + return None + + if 'disable' in mdns: + print('Warning: mDNS repeater will be deactivated because it is disabled') + return None + + if mdns['vrrp_exists'] and 'vrrp_disable' in mdns: + mdns['interface'] = get_vrrp_master(mdns['interface']) + + if len(mdns['interface']) < 2: + return None + + render(config_file, 'mdns-repeater/avahi-daemon.conf.j2', mdns) + render(systemd_override, 'mdns-repeater/override.conf.j2', mdns) + return None + +def apply(mdns): + systemd_service = 'avahi-daemon.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + + if not mdns or 'disable' in mdns: + call(f'systemctl stop {systemd_service}') + if os.path.exists(config_file): + os.unlink(config_file) + + if os.path.exists(vrrp_running_file): + os.unlink(vrrp_running_file) + else: + if 'vrrp_disable' not in mdns and os.path.exists(vrrp_running_file): + os.unlink(vrrp_running_file) + + if mdns['vrrp_exists'] and 'vrrp_disable' in mdns: + if not os.path.exists(vrrp_running_file): + os.mknod(vrrp_running_file) # vrrp script looks for this file to update mdns repeater + + if len(mdns['interface']) < 2: + call(f'systemctl stop {systemd_service}') + return None + + call(f'systemctl restart {systemd_service}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py new file mode 100755 index 000000000..1cc23a7df --- /dev/null +++ b/src/conf_mode/service_ntp.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from vyos.config import Config +from vyos.configdict import is_node_changed +from vyos.configverify import verify_vrf +from vyos.configverify import verify_interface_exists +from vyos.utils.process import call +from vyos.utils.permission import chmod_750 +from vyos.utils.network import get_interface_config +from vyos.template import render +from vyos.template import is_ipv4 +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/chrony/chrony.conf' +systemd_override = r'/run/systemd/system/chrony.service.d/override.conf' +user_group = '_chrony' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'ntp'] + if not conf.exists(base): + return None + + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + ntp['config_file'] = config_file + ntp['user'] = user_group + + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: ntp.update({'restart_required': {}}) + + return ntp + +def verify(ntp): + # bail out early - looks like removal from running config + if not ntp: + return None + + if 'server' not in ntp: + raise ConfigError('NTP server not configured') + + verify_vrf(ntp) + + if 'interface' in ntp: + # If ntpd should listen on a given interface, ensure it exists + interface = ntp['interface'] + verify_interface_exists(interface) + + # If we run in a VRF, our interface must belong to this VRF, too + if 'vrf' in ntp: + tmp = get_interface_config(interface) + vrf_name = ntp['vrf'] + if 'master' not in tmp or tmp['master'] != vrf_name: + raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ + f'does not belong to this VRF!') + + if 'listen_address' in ntp: + ipv4_addresses = 0 + ipv6_addresses = 0 + for address in ntp['listen_address']: + if is_ipv4(address): + ipv4_addresses += 1 + else: + ipv6_addresses += 1 + if ipv4_addresses > 1: + raise ConfigError(f'NTP Only admits one ipv4 value for listen-address parameter ') + if ipv6_addresses > 1: + raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ') + + return None + +def generate(ntp): + # bail out early - looks like removal from running config + if not ntp: + return None + + render(config_file, 'chrony/chrony.conf.j2', ntp, user=user_group, group=user_group) + render(systemd_override, 'chrony/override.conf.j2', ntp, user=user_group, group=user_group) + + # Ensure proper permission for chrony command socket + config_dir = os.path.dirname(config_file) + chmod_750(config_dir) + + return None + +def apply(ntp): + systemd_service = 'chrony.service' + # Reload systemd manager configuration + call('systemctl daemon-reload') + + if not ntp: + # NTP support is removed in the commit + call(f'systemctl stop {systemd_service}') + if os.path.exists(config_file): + os.unlink(config_file) + if os.path.isfile(systemd_override): + os.unlink(systemd_override) + return + + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in ntp: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {systemd_service}') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/service_salt-minion.py b/src/conf_mode/service_salt-minion.py new file mode 100755 index 000000000..a8fce8e01 --- /dev/null +++ b/src/conf_mode/service_salt-minion.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 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 . + +import os + +from socket import gethostname +from sys import exit +from urllib3 import PoolManager + +from vyos.base import Warning +from vyos.config import Config +from vyos.configverify import verify_interface_exists +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.permission import chown +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +config_file = r'/etc/salt/minion' +master_keyfile = r'/opt/vyatta/etc/config/salt/pki/minion/master_sign.pub' + +user='minion' +group='vyattacfg' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'salt-minion'] + + if not conf.exists(base): + return None + + salt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + # ID default is dynamic thus we can not use defaults() + if 'id' not in salt: + salt['id'] = gethostname() + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + salt = conf.merge_defaults(salt, recursive=True) + + if not conf.exists(base): + return None + else: + conf.set_level(base) + + return salt + +def verify(salt): + if not salt: + return None + + if 'hash' in salt and salt['hash'] == 'sha1': + Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!') + + if 'source_interface' in salt: + verify_interface_exists(salt['source_interface']) + + return None + +def generate(salt): + if not salt: + return None + + render(config_file, 'salt-minion/minion.j2', salt, user=user, group=group) + + if not os.path.exists(master_keyfile): + if 'master_key' in salt: + req = PoolManager().request('GET', salt['master_key'], preload_content=False) + with open(master_keyfile, 'wb') as f: + while True: + data = req.read(1024) + if not data: + break + f.write(data) + + req.release_conn() + chown(master_keyfile, user, group) + + return None + +def apply(salt): + service_name = 'salt-minion.service' + if not salt: + # Salt removed from running config + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + else: + call(f'systemctl restart {service_name}') + + 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_snmp.py b/src/conf_mode/service_snmp.py new file mode 100755 index 000000000..6565ffd60 --- /dev/null +++ b/src/conf_mode/service_snmp.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from sys import exit + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configverify import verify_vrf +from vyos.snmpv3_hashgen import plaintext_to_md5 +from vyos.snmpv3_hashgen import plaintext_to_sha1 +from vyos.snmpv3_hashgen import random +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.permission import chmod_755 +from vyos.utils.dict import dict_search +from vyos.utils.network import is_addr_assigned +from vyos.version import get_version_data +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file_client = r'/etc/snmp/snmp.conf' +config_file_daemon = r'/etc/snmp/snmpd.conf' +config_file_access = r'/usr/share/snmp/snmpd.conf' +config_file_user = r'/var/lib/snmp/snmpd.conf' +systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' +systemd_service = 'snmpd.service' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'snmp'] + + snmp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + if not conf.exists(base): + snmp.update({'deleted' : ''}) + + if conf.exists(['service', 'lldp', 'snmp']): + snmp.update({'lldp_snmp' : ''}) + + if 'deleted' in snmp: + return snmp + + version_data = get_version_data() + snmp['version'] = version_data['version'] + + # create an internal snmpv3 user of the form 'vyosxxxxxxxxxxxxxxxx' + snmp['vyos_user'] = 'vyos' + random(8) + snmp['vyos_user_pass'] = random(16) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + snmp = conf.merge_defaults(snmp, recursive=True) + + if 'listen_address' in snmp: + # Always listen on localhost if an explicit address has been configured + # This is a safety measure to not end up with invalid listen addresses + # that are not configured on this system. See https://vyos.dev/T850 + if '127.0.0.1' not in snmp['listen_address']: + tmp = {'127.0.0.1': {'port': '161'}} + snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + + if '::1' not in snmp['listen_address']: + tmp = {'::1': {'port': '161'}} + snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) + + return snmp + +def verify(snmp): + if 'deleted' in snmp: + return None + + if {'deleted', 'lldp_snmp'} <= set(snmp): + raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!') + + ### check if the configured script actually exist + if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']: + for extension, extension_opt in snmp['script_extensions']['extension_name'].items(): + if 'script' not in extension_opt: + raise ConfigError(f'Script extension "{extension}" requires an actual script to be configured!') + + tmp = extension_opt['script'] + if not os.path.isfile(tmp): + Warning(f'script "{tmp}" does not exist!') + else: + chmod_755(extension_opt['script']) + + if 'listen_address' in snmp: + for address in snmp['listen_address']: + # We only wan't to configure addresses that exist on the system. + # Hint the user if they don't exist + if 'vrf' in snmp: + vrf_name = snmp['vrf'] + if not is_addr_assigned(address, vrf_name) and address not in ['::1','127.0.0.1']: + raise ConfigError(f'SNMP listen address "{address}" not configured in vrf "{vrf_name}"!') + elif not is_addr_assigned(address): + raise ConfigError(f'SNMP listen address "{address}" not configured in default vrf!') + + if 'trap_target' in snmp: + for trap, trap_config in snmp['trap_target'].items(): + if 'community' not in trap_config: + raise ConfigError(f'Trap target "{trap}" requires a community to be set!') + + if 'oid_enable' in snmp: + Warning(f'Custom OIDs are enabled and may lead to system instability and high resource consumption') + + + verify_vrf(snmp) + + # bail out early if SNMP v3 is not configured + if 'v3' not in snmp: + return None + + if 'user' in snmp['v3']: + for user, user_config in snmp['v3']['user'].items(): + if 'group' not in user_config: + raise ConfigError(f'Group membership required for user "{user}"!') + + if 'plaintext_password' not in user_config['auth'] and 'encrypted_password' not in user_config['auth']: + raise ConfigError(f'Must specify authentication encrypted-password or plaintext-password for user "{user}"!') + + if 'plaintext_password' not in user_config['privacy'] and 'encrypted_password' not in user_config['privacy']: + raise ConfigError(f'Must specify privacy encrypted-password or plaintext-password for user "{user}"!') + + if 'group' in snmp['v3']: + for group, group_config in snmp['v3']['group'].items(): + if 'seclevel' not in group_config: + raise ConfigError(f'Must configure "seclevel" for group "{group}"!') + if 'view' not in group_config: + raise ConfigError(f'Must configure "view" for group "{group}"!') + + # Check if 'view' exists + view = group_config['view'] + if 'view' not in snmp['v3'] or view not in snmp['v3']['view']: + raise ConfigError(f'You must create view "{view}" first!') + + if 'view' in snmp['v3']: + for view, view_config in snmp['v3']['view'].items(): + if 'oid' not in view_config: + raise ConfigError(f'Must configure an "oid" for view "{view}"!') + + if 'trap_target' in snmp['v3']: + for trap, trap_config in snmp['v3']['trap_target'].items(): + if 'plaintext_password' not in trap_config['auth'] and 'encrypted_password' not in trap_config['auth']: + raise ConfigError(f'Must specify one of authentication encrypted-password or plaintext-password for trap "{trap}"!') + + if {'plaintext_password', 'encrypted_password'} <= set(trap_config['auth']): + raise ConfigError(f'Can not specify both authentication encrypted-password and plaintext-password for trap "{trap}"!') + + if 'plaintext_password' not in trap_config['privacy'] and 'encrypted_password' not in trap_config['privacy']: + raise ConfigError(f'Must specify one of privacy encrypted-password or plaintext-password for trap "{trap}"!') + + if {'plaintext_password', 'encrypted_password'} <= set(trap_config['privacy']): + raise ConfigError(f'Can not specify both privacy encrypted-password and plaintext-password for trap "{trap}"!') + + if 'type' not in trap_config: + raise ConfigError('SNMP v3 trap "type" must be specified!') + + return None + +def generate(snmp): + # As we are manipulating the snmpd user database we have to stop it first! + # This is even save if service is going to be removed + call(f'systemctl stop {systemd_service}') + # Clean config files + config_files = [config_file_client, config_file_daemon, + config_file_access, config_file_user, systemd_override] + for file in config_files: + if os.path.isfile(file): + os.unlink(file) + + if 'deleted' in snmp: + return None + + if 'v3' in snmp: + # net-snmp is now regenerating the configuration file in the background + # thus we need to re-open and re-read the file as the content changed. + # After that we can no read the encrypted password from the config and + # replace the CLI plaintext password with its encrypted version. + os.environ['vyos_libexec_dir'] = '/usr/libexec/vyos' + + if 'user' in snmp['v3']: + for user, user_config in snmp['v3']['user'].items(): + if dict_search('auth.type', user_config) == 'sha': + hash = plaintext_to_sha1 + else: + hash = plaintext_to_md5 + + if dict_search('auth.plaintext_password', user_config) is not None: + tmp = hash(dict_search('auth.plaintext_password', user_config), + dict_search('v3.engineid', snmp)) + + snmp['v3']['user'][user]['auth']['encrypted_password'] = tmp + del snmp['v3']['user'][user]['auth']['plaintext_password'] + + call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" auth encrypted-password "{tmp}" > /dev/null') + call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" auth plaintext-password > /dev/null') + + if dict_search('privacy.plaintext_password', user_config) is not None: + tmp = hash(dict_search('privacy.plaintext_password', user_config), + dict_search('v3.engineid', snmp)) + + snmp['v3']['user'][user]['privacy']['encrypted_password'] = tmp + del snmp['v3']['user'][user]['privacy']['plaintext_password'] + + call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" privacy encrypted-password "{tmp}" > /dev/null') + call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" privacy plaintext-password > /dev/null') + + # Write client config file + render(config_file_client, 'snmp/etc.snmp.conf.j2', snmp) + # Write server config file + render(config_file_daemon, 'snmp/etc.snmpd.conf.j2', snmp) + # Write access rights config file + render(config_file_access, 'snmp/usr.snmpd.conf.j2', snmp) + # Write access rights config file + render(config_file_user, 'snmp/var.snmpd.conf.j2', snmp) + # Write daemon configuration file + render(systemd_override, 'snmp/override.conf.j2', snmp) + + return None + +def apply(snmp): + # Always reload systemd manager configuration + call('systemctl daemon-reload') + + if 'deleted' in snmp: + return None + + # start SNMP daemon + call(f'systemctl 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__': + 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_ssh.py b/src/conf_mode/service_ssh.py new file mode 100755 index 000000000..ee5e1eca2 --- /dev/null +++ b/src/conf_mode/service_ssh.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 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 . + +import os + +from sys import exit +from syslog import syslog +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.utils.process import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/sshd/sshd_config' +systemd_override = r'/run/systemd/system/ssh.service.d/override.conf' + +sshguard_config_file = '/etc/sshguard/sshguard.conf' +sshguard_whitelist = '/etc/sshguard/whitelist' + +key_rsa = '/etc/ssh/ssh_host_rsa_key' +key_dsa = '/etc/ssh/ssh_host_dsa_key' +key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'ssh'] + if not conf.exists(base): + return None + + ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + tmp = is_node_changed(conf, base + ['vrf']) + if tmp: ssh.update({'restart_required': {}}) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + ssh = conf.merge_defaults(ssh, recursive=True) + + # pass config file path - used in override template + ssh['config_file'] = config_file + + # Ignore default XML values if config doesn't exists + # Delete key from dict + if not conf.exists(base + ['dynamic-protection']): + del ssh['dynamic_protection'] + + 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!') + + verify_vrf(ssh) + return None + +def generate(ssh): + if not ssh: + if os.path.isfile(config_file): + os.unlink(config_file) + if os.path.isfile(systemd_override): + os.unlink(systemd_override) + + return None + + # This usually happens only once on a fresh system, SSH keys need to be + # freshly generted, one per every system! + if not os.path.isfile(key_rsa): + syslog(LOG_INFO, 'SSH RSA host key not found, generating new key!') + call(f'ssh-keygen -q -N "" -t rsa -f {key_rsa}') + if not os.path.isfile(key_dsa): + syslog(LOG_INFO, 'SSH DSA host key not found, generating new key!') + call(f'ssh-keygen -q -N "" -t dsa -f {key_dsa}') + if not os.path.isfile(key_ed25519): + syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!') + call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') + + render(config_file, 'ssh/sshd_config.j2', ssh) + render(systemd_override, 'ssh/override.conf.j2', ssh) + + if 'dynamic_protection' in ssh: + render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) + render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh) + # Reload systemd manager configuration + call('systemctl daemon-reload') + + 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 {systemd_service_ssh}') + call(f'systemctl stop {systemd_service_sshguard}') + return None + + if 'dynamic_protection' not in ssh: + call(f'systemctl stop {systemd_service_sshguard}') + else: + call(f'systemctl reload-or-restart {systemd_service_sshguard}') + + # we need to restart the service if e.g. the VRF name changed + systemd_action = 'reload-or-restart' + if 'restart_required' in ssh: + systemd_action = 'restart' + + call(f'systemctl {systemd_action} {systemd_service_ssh}') + 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_tftp-server.py b/src/conf_mode/service_tftp-server.py new file mode 100755 index 000000000..3ad346e2e --- /dev/null +++ b/src/conf_mode/service_tftp-server.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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 . + +import os +import stat +import pwd + +from copy import deepcopy +from glob import glob +from sys import exit + +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.process import call +from vyos.utils.permission import chmod_755 +from vyos.utils.network import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/etc/default/tftpd' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['service', 'tftp-server'] + if not conf.exists(base): + return None + + tftpd = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + return tftpd + +def verify(tftpd): + # bail out early - looks like removal from running config + if not tftpd: + return None + + # Configuring allowed clients without a server makes no sense + if 'directory' not in tftpd: + raise ConfigError('TFTP root directory must be configured!') + + if 'listen_address' not in tftpd: + raise ConfigError('TFTP server listen address must be configured!') + + for address, address_config in tftpd['listen_address'].items(): + if not is_addr_assigned(address): + Warning(f'TFTP server listen address "{address}" not ' \ + 'assigned to any interface!') + verify_vrf(address_config) + + return None + +def generate(tftpd): + # cleanup any available configuration file + # files will be recreated on demand + for i in glob(config_file + '*'): + os.unlink(i) + + # bail out early - looks like removal from running config + if tftpd is None: + return None + + idx = 0 + for address, address_config in tftpd['listen_address'].items(): + config = deepcopy(tftpd) + port = tftpd['port'] + if is_ipv4(address): + config['listen_address'] = f'{address}:{port} -4' + else: + config['listen_address'] = f'[{address}]:{port} -6' + + if 'vrf' in address_config: + config['vrf'] = address_config['vrf'] + + file = config_file + str(idx) + render(file, 'tftp-server/default.j2', config) + idx = idx + 1 + + return None + +def apply(tftpd): + # stop all services first - then we will decide + call('systemctl stop tftpd@*.service') + + # bail out early - e.g. service deletion + if tftpd is None: + return None + + tftp_root = tftpd['directory'] + if not os.path.exists(tftp_root): + os.makedirs(tftp_root) + chmod_755(tftp_root) + + # get UNIX uid for user 'tftp' + tftp_uid = pwd.getpwnam('tftp').pw_uid + tftp_gid = pwd.getpwnam('tftp').pw_gid + + # get UNIX uid for tftproot directory + dir_uid = os.stat(tftp_root).st_uid + dir_gid = os.stat(tftp_root).st_gid + + # adjust uid/gid of tftproot directory if files don't belong to user tftp + if (tftp_uid != dir_uid) or (tftp_gid != dir_gid): + os.chown(tftp_root, tftp_uid, tftp_gid) + + idx = 0 + for address in tftpd['listen_address']: + call(f'systemctl restart tftpd@{idx}.service') + idx = idx + 1 + + 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/snmp.py b/src/conf_mode/snmp.py deleted file mode 100755 index 6565ffd60..000000000 --- a/src/conf_mode/snmp.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os - -from sys import exit - -from vyos.base import Warning -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configverify import verify_vrf -from vyos.snmpv3_hashgen import plaintext_to_md5 -from vyos.snmpv3_hashgen import plaintext_to_sha1 -from vyos.snmpv3_hashgen import random -from vyos.template import render -from vyos.utils.process import call -from vyos.utils.permission import chmod_755 -from vyos.utils.dict import dict_search -from vyos.utils.network import is_addr_assigned -from vyos.version import get_version_data -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file_client = r'/etc/snmp/snmp.conf' -config_file_daemon = r'/etc/snmp/snmpd.conf' -config_file_access = r'/usr/share/snmp/snmpd.conf' -config_file_user = r'/var/lib/snmp/snmpd.conf' -systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' -systemd_service = 'snmpd.service' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'snmp'] - - snmp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - if not conf.exists(base): - snmp.update({'deleted' : ''}) - - if conf.exists(['service', 'lldp', 'snmp']): - snmp.update({'lldp_snmp' : ''}) - - if 'deleted' in snmp: - return snmp - - version_data = get_version_data() - snmp['version'] = version_data['version'] - - # create an internal snmpv3 user of the form 'vyosxxxxxxxxxxxxxxxx' - snmp['vyos_user'] = 'vyos' + random(8) - snmp['vyos_user_pass'] = random(16) - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - snmp = conf.merge_defaults(snmp, recursive=True) - - if 'listen_address' in snmp: - # Always listen on localhost if an explicit address has been configured - # This is a safety measure to not end up with invalid listen addresses - # that are not configured on this system. See https://vyos.dev/T850 - if '127.0.0.1' not in snmp['listen_address']: - tmp = {'127.0.0.1': {'port': '161'}} - snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) - - if '::1' not in snmp['listen_address']: - tmp = {'::1': {'port': '161'}} - snmp['listen_address'] = dict_merge(tmp, snmp['listen_address']) - - return snmp - -def verify(snmp): - if 'deleted' in snmp: - return None - - if {'deleted', 'lldp_snmp'} <= set(snmp): - raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!') - - ### check if the configured script actually exist - if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']: - for extension, extension_opt in snmp['script_extensions']['extension_name'].items(): - if 'script' not in extension_opt: - raise ConfigError(f'Script extension "{extension}" requires an actual script to be configured!') - - tmp = extension_opt['script'] - if not os.path.isfile(tmp): - Warning(f'script "{tmp}" does not exist!') - else: - chmod_755(extension_opt['script']) - - if 'listen_address' in snmp: - for address in snmp['listen_address']: - # We only wan't to configure addresses that exist on the system. - # Hint the user if they don't exist - if 'vrf' in snmp: - vrf_name = snmp['vrf'] - if not is_addr_assigned(address, vrf_name) and address not in ['::1','127.0.0.1']: - raise ConfigError(f'SNMP listen address "{address}" not configured in vrf "{vrf_name}"!') - elif not is_addr_assigned(address): - raise ConfigError(f'SNMP listen address "{address}" not configured in default vrf!') - - if 'trap_target' in snmp: - for trap, trap_config in snmp['trap_target'].items(): - if 'community' not in trap_config: - raise ConfigError(f'Trap target "{trap}" requires a community to be set!') - - if 'oid_enable' in snmp: - Warning(f'Custom OIDs are enabled and may lead to system instability and high resource consumption') - - - verify_vrf(snmp) - - # bail out early if SNMP v3 is not configured - if 'v3' not in snmp: - return None - - if 'user' in snmp['v3']: - for user, user_config in snmp['v3']['user'].items(): - if 'group' not in user_config: - raise ConfigError(f'Group membership required for user "{user}"!') - - if 'plaintext_password' not in user_config['auth'] and 'encrypted_password' not in user_config['auth']: - raise ConfigError(f'Must specify authentication encrypted-password or plaintext-password for user "{user}"!') - - if 'plaintext_password' not in user_config['privacy'] and 'encrypted_password' not in user_config['privacy']: - raise ConfigError(f'Must specify privacy encrypted-password or plaintext-password for user "{user}"!') - - if 'group' in snmp['v3']: - for group, group_config in snmp['v3']['group'].items(): - if 'seclevel' not in group_config: - raise ConfigError(f'Must configure "seclevel" for group "{group}"!') - if 'view' not in group_config: - raise ConfigError(f'Must configure "view" for group "{group}"!') - - # Check if 'view' exists - view = group_config['view'] - if 'view' not in snmp['v3'] or view not in snmp['v3']['view']: - raise ConfigError(f'You must create view "{view}" first!') - - if 'view' in snmp['v3']: - for view, view_config in snmp['v3']['view'].items(): - if 'oid' not in view_config: - raise ConfigError(f'Must configure an "oid" for view "{view}"!') - - if 'trap_target' in snmp['v3']: - for trap, trap_config in snmp['v3']['trap_target'].items(): - if 'plaintext_password' not in trap_config['auth'] and 'encrypted_password' not in trap_config['auth']: - raise ConfigError(f'Must specify one of authentication encrypted-password or plaintext-password for trap "{trap}"!') - - if {'plaintext_password', 'encrypted_password'} <= set(trap_config['auth']): - raise ConfigError(f'Can not specify both authentication encrypted-password and plaintext-password for trap "{trap}"!') - - if 'plaintext_password' not in trap_config['privacy'] and 'encrypted_password' not in trap_config['privacy']: - raise ConfigError(f'Must specify one of privacy encrypted-password or plaintext-password for trap "{trap}"!') - - if {'plaintext_password', 'encrypted_password'} <= set(trap_config['privacy']): - raise ConfigError(f'Can not specify both privacy encrypted-password and plaintext-password for trap "{trap}"!') - - if 'type' not in trap_config: - raise ConfigError('SNMP v3 trap "type" must be specified!') - - return None - -def generate(snmp): - # As we are manipulating the snmpd user database we have to stop it first! - # This is even save if service is going to be removed - call(f'systemctl stop {systemd_service}') - # Clean config files - config_files = [config_file_client, config_file_daemon, - config_file_access, config_file_user, systemd_override] - for file in config_files: - if os.path.isfile(file): - os.unlink(file) - - if 'deleted' in snmp: - return None - - if 'v3' in snmp: - # net-snmp is now regenerating the configuration file in the background - # thus we need to re-open and re-read the file as the content changed. - # After that we can no read the encrypted password from the config and - # replace the CLI plaintext password with its encrypted version. - os.environ['vyos_libexec_dir'] = '/usr/libexec/vyos' - - if 'user' in snmp['v3']: - for user, user_config in snmp['v3']['user'].items(): - if dict_search('auth.type', user_config) == 'sha': - hash = plaintext_to_sha1 - else: - hash = plaintext_to_md5 - - if dict_search('auth.plaintext_password', user_config) is not None: - tmp = hash(dict_search('auth.plaintext_password', user_config), - dict_search('v3.engineid', snmp)) - - snmp['v3']['user'][user]['auth']['encrypted_password'] = tmp - del snmp['v3']['user'][user]['auth']['plaintext_password'] - - call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" auth encrypted-password "{tmp}" > /dev/null') - call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" auth plaintext-password > /dev/null') - - if dict_search('privacy.plaintext_password', user_config) is not None: - tmp = hash(dict_search('privacy.plaintext_password', user_config), - dict_search('v3.engineid', snmp)) - - snmp['v3']['user'][user]['privacy']['encrypted_password'] = tmp - del snmp['v3']['user'][user]['privacy']['plaintext_password'] - - call(f'/opt/vyatta/sbin/my_set service snmp v3 user "{user}" privacy encrypted-password "{tmp}" > /dev/null') - call(f'/opt/vyatta/sbin/my_delete service snmp v3 user "{user}" privacy plaintext-password > /dev/null') - - # Write client config file - render(config_file_client, 'snmp/etc.snmp.conf.j2', snmp) - # Write server config file - render(config_file_daemon, 'snmp/etc.snmpd.conf.j2', snmp) - # Write access rights config file - render(config_file_access, 'snmp/usr.snmpd.conf.j2', snmp) - # Write access rights config file - render(config_file_user, 'snmp/var.snmpd.conf.j2', snmp) - # Write daemon configuration file - render(systemd_override, 'snmp/override.conf.j2', snmp) - - return None - -def apply(snmp): - # Always reload systemd manager configuration - call('systemctl daemon-reload') - - if 'deleted' in snmp: - return None - - # start SNMP daemon - call(f'systemctl 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__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/ssh.py b/src/conf_mode/ssh.py deleted file mode 100755 index ee5e1eca2..000000000 --- a/src/conf_mode/ssh.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 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 . - -import os - -from sys import exit -from syslog import syslog -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.utils.process import call -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/sshd/sshd_config' -systemd_override = r'/run/systemd/system/ssh.service.d/override.conf' - -sshguard_config_file = '/etc/sshguard/sshguard.conf' -sshguard_whitelist = '/etc/sshguard/whitelist' - -key_rsa = '/etc/ssh/ssh_host_rsa_key' -key_dsa = '/etc/ssh/ssh_host_dsa_key' -key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'ssh'] - if not conf.exists(base): - return None - - ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - - tmp = is_node_changed(conf, base + ['vrf']) - if tmp: ssh.update({'restart_required': {}}) - - # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. - ssh = conf.merge_defaults(ssh, recursive=True) - - # pass config file path - used in override template - ssh['config_file'] = config_file - - # Ignore default XML values if config doesn't exists - # Delete key from dict - if not conf.exists(base + ['dynamic-protection']): - del ssh['dynamic_protection'] - - 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!') - - verify_vrf(ssh) - return None - -def generate(ssh): - if not ssh: - if os.path.isfile(config_file): - os.unlink(config_file) - if os.path.isfile(systemd_override): - os.unlink(systemd_override) - - return None - - # This usually happens only once on a fresh system, SSH keys need to be - # freshly generted, one per every system! - if not os.path.isfile(key_rsa): - syslog(LOG_INFO, 'SSH RSA host key not found, generating new key!') - call(f'ssh-keygen -q -N "" -t rsa -f {key_rsa}') - if not os.path.isfile(key_dsa): - syslog(LOG_INFO, 'SSH DSA host key not found, generating new key!') - call(f'ssh-keygen -q -N "" -t dsa -f {key_dsa}') - if not os.path.isfile(key_ed25519): - syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!') - call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') - - render(config_file, 'ssh/sshd_config.j2', ssh) - render(systemd_override, 'ssh/override.conf.j2', ssh) - - if 'dynamic_protection' in ssh: - render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) - render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh) - # Reload systemd manager configuration - call('systemctl daemon-reload') - - 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 {systemd_service_ssh}') - call(f'systemctl stop {systemd_service_sshguard}') - return None - - if 'dynamic_protection' not in ssh: - call(f'systemctl stop {systemd_service_sshguard}') - else: - call(f'systemctl reload-or-restart {systemd_service_sshguard}') - - # we need to restart the service if e.g. the VRF name changed - systemd_action = 'reload-or-restart' - if 'restart_required' in ssh: - systemd_action = 'restart' - - call(f'systemctl {systemd_action} {systemd_service_ssh}') - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py deleted file mode 100755 index 7612e2c0d..000000000 --- a/src/conf_mode/system-ip.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -from sys import exit - -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configverify import verify_route_map -from vyos.template import render_to_string -from vyos.utils.dict import dict_search -from vyos.utils.file import write_file -from vyos.utils.process import call -from vyos.utils.process import is_systemd_service_active -from vyos.utils.system import sysctl_write - -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() - 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) - return opt - -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 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)) - # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) - # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) - # 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) - - # enable/disable IPv4 directed broadcast forwarding - tmp = dict_search('disable_directed_broadcast', opt) - value = '0' if (tmp != None) else '1' - write_file('/proc/sys/net/ipv4/conf/all/bc_forwarding', value) - - # configure multipath - tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) - value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_use_neigh', value) - - tmp = dict_search('multipath.layer4_hashing', opt) - value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_hash_policy', value) - - # configure TCP options (defaults as of Linux 6.4) - tmp = dict_search('tcp.mss.probing', opt) - if tmp is None: - value = 0 - elif tmp == 'on-icmp-black-hole': - value = 1 - elif tmp == 'force': - value = 2 - else: - # Shouldn't happen - raise ValueError("TCP MSS probing is neither 'on-icmp-black-hole' nor 'force'!") - sysctl_write('net.ipv4.tcp_mtu_probing', value) - - tmp = dict_search('tcp.mss.base', opt) - value = '1024' if (tmp is None) else tmp - sysctl_write('net.ipv4.tcp_base_mss', value) - - tmp = dict_search('tcp.mss.floor', opt) - value = '48' if (tmp is None) else tmp - sysctl_write('net.ipv4.tcp_mtu_probe_floor', value) - - # During startup of vyos-router that brings up FRR, the service is not yet - # running when this script is called first. Skip this part and wait for initial - # 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'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 __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-ipv6.py b/src/conf_mode/system-ipv6.py deleted file mode 100755 index 90a1a8087..000000000 --- a/src/conf_mode/system-ipv6.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os - -from sys import exit -from vyos.config import Config -from vyos.configdict import dict_merge -from vyos.configverify import verify_route_map -from vyos.template import render_to_string -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.system import sysctl_write -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() - 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) - return opt - -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 apply(opt): - # configure multipath - tmp = dict_search('multipath.layer4_hashing', opt) - value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv6.fib_multipath_hash_policy', value) - - # Apply ND threshold values - # table_size has a default value - thus the key always exists - size = int(dict_search('neighbor.table_size', opt)) - # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) - # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) - # Minimum number of stored records is indicated which is not cleared - sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) - - # 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' - for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'): - for name in files: - if name == 'accept_dad': - write_file(os.path.join(root, name), value) - - # During startup of vyos-router that brings up FRR, the service is not yet - # running when this script is called first. Skip this part and wait for initial - # 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'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 __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py deleted file mode 100755 index 65fa04417..000000000 --- a/src/conf_mode/system-login-banner.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-2021 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 . - -from sys import exit -from copy import deepcopy - -from vyos.config import Config -from vyos.utils.file import write_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -try: - with open('/usr/share/vyos/default_motd') as f: - motd = f.read() -except: - # Use an empty banner if the default banner file cannot be read - motd = "\n" - -PRELOGIN_FILE = r'/etc/issue' -PRELOGIN_NET_FILE = r'/etc/issue.net' -POSTLOGIN_FILE = r'/etc/motd' - -default_config_data = { - 'issue': 'Welcome to VyOS - \\n \\l\n\n', - 'issue_net': '', - 'motd': motd -} - -def get_config(config=None): - banner = deepcopy(default_config_data) - if config: - conf = config - else: - conf = Config() - base_level = ['system', 'login', 'banner'] - - if not conf.exists(base_level): - return banner - else: - conf.set_level(base_level) - - # Post-Login banner - if conf.exists(['post-login']): - tmp = conf.return_value(['post-login']) - # post-login banner can be empty as well - if tmp: - tmp = tmp.replace('\\n','\n') - tmp = tmp.replace('\\t','\t') - # always add newline character - tmp += '\n' - else: - tmp = '' - - banner['motd'] = tmp - - # Pre-Login banner - if conf.exists(['pre-login']): - tmp = conf.return_value(['pre-login']) - # pre-login banner can be empty as well - if tmp: - tmp = tmp.replace('\\n','\n') - tmp = tmp.replace('\\t','\t') - # always add newline character - tmp += '\n' - else: - tmp = '' - - banner['issue'] = banner['issue_net'] = tmp - - return banner - -def verify(banner): - pass - -def generate(banner): - pass - -def apply(banner): - write_file(PRELOGIN_FILE, banner['issue']) - write_file(PRELOGIN_NET_FILE, banner['issue_net']) - write_file(POSTLOGIN_FILE, banner['motd']) - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py deleted file mode 100755 index f34575aff..000000000 --- a/src/conf_mode/system-login.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2020-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 . - -import os - -from passlib.hosts import linux_context -from psutil import users -from pwd import getpwall -from pwd import getpwnam -from sys import exit -from time import sleep - -from vyos.config import Config -from vyos.configverify import verify_vrf -from vyos.defaults import directories -from vyos.template import render -from vyos.template import is_ipv4 -from vyos.utils.dict import dict_search -from vyos.utils.file import chown -from vyos.utils.process import cmd -from vyos.utils.process import call -from vyos.utils.process import rc_cmd -from vyos.utils.process import run -from vyos.utils.process import DEVNULL -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -autologout_file = "/etc/profile.d/autologout.sh" -limits_file = "/etc/security/limits.d/10-vyos.conf" -radius_config_file = "/etc/pam_radius_auth.conf" -tacacs_pam_config_file = "/etc/tacplus_servers" -tacacs_nss_config_file = "/etc/tacplus_nss.conf" -nss_config_file = "/etc/nsswitch.conf" - -# Minimum UID used when adding system users -MIN_USER_UID: int = 1000 -# Maximim UID used when adding system users -MAX_USER_UID: int = 59999 -# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec -MAX_RADIUS_TIMEOUT: int = 50 -# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout) -MAX_RADIUS_COUNT: int = 8 -# Maximum number of supported TACACS servers -MAX_TACACS_COUNT: int = 8 - -# 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(): - """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: - continue - if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID: - continue - if s_user.pw_name in SYSTEM_USER_SKIP_LIST: - continue - local_users.append(s_user.pw_name) - - return local_users - -def get_shadow_password(username): - with open('/etc/shadow') as f: - for user in f.readlines(): - items = user.split(":") - if username == items[0]: - return items[1] - return None - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'login'] - login = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) - - # users no longer existing in the running configuration need to be deleted - local_users = get_local_users() - cli_users = [] - if 'user' in login: - cli_users = list(login['user']) - - # prune TACACS global defaults if not set by user - if login.from_defaults(['tacacs']): - del login['tacacs'] - # same for RADIUS - if login.from_defaults(['radius']): - del login['radius'] - - # create a list of all users, cli and users - all_users = list(set(local_users + cli_users)) - # We will remove any normal users that dos not exist in the current - # configuration. This can happen if user is added but configuration was not - # saved and system is rebooted. - rm_users = [tmp for tmp in all_users if tmp not in cli_users] - if rm_users: login.update({'rm_users' : rm_users}) - - return login - -def verify(login): - if 'rm_users' in login: - # This check is required as the script is also executed from vyos-router - # init script and there is no SUDO_USER environment variable available - # during system boot. - if 'SUDO_USER' in os.environ: - cur_user = os.environ['SUDO_USER'] - if cur_user in login['rm_users']: - raise ConfigError(f'Attempting to delete current user: {cur_user}') - - if 'user' in login: - system_users = getpwall() - for user, user_config in login['user'].items(): - # Linux system users range up until UID 1000, we can not create a - # VyOS CLI user which already exists as system user - for s_user in system_users: - 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!') - - 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}"!') - if 'key' not in pubkey_options: - raise ConfigError(f'Missing key for public-key "{pubkey}"!') - - if {'radius', 'tacacs'} <= set(login): - raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') - - # At lease one RADIUS server must not be disabled - if 'radius' in login: - if 'server' not in login['radius']: - raise ConfigError('No RADIUS server defined!') - sum_timeout: int = 0 - radius_servers_count: int = 0 - fail = True - for server, server_config in dict_search('radius.server', login).items(): - if 'key' not in server_config: - raise ConfigError(f'RADIUS server "{server}" requires key!') - if 'disable' not in server_config: - sum_timeout += int(server_config['timeout']) - radius_servers_count += 1 - fail = False - - if fail: - raise ConfigError('All RADIUS servers are disabled') - - if radius_servers_count > MAX_RADIUS_COUNT: - raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!') - - if sum_timeout > MAX_RADIUS_TIMEOUT: - raise ConfigError('Sum of RADIUS servers timeouts ' - 'has to be less or eq 50 sec') - - verify_vrf(login['radius']) - - if 'source_address' in login['radius']: - ipv4_count = 0 - ipv6_count = 0 - for address in login['radius']['source_address']: - if is_ipv4(address): ipv4_count += 1 - else: ipv6_count += 1 - - if ipv4_count > 1: - raise ConfigError('Only one IPv4 source-address can be set!') - if ipv6_count > 1: - raise ConfigError('Only one IPv6 source-address can be set!') - - if 'tacacs' in login: - tacacs_servers_count: int = 0 - fail = True - for server, server_config in dict_search('tacacs.server', login).items(): - if 'key' not in server_config: - raise ConfigError(f'TACACS server "{server}" requires key!') - if 'disable' not in server_config: - tacacs_servers_count += 1 - fail = False - - if fail: - raise ConfigError('All RADIUS servers are disabled') - - if tacacs_servers_count > MAX_TACACS_COUNT: - raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') - - verify_vrf(login['tacacs']) - - if 'max_login_session' in login and 'timeout' not in login: - raise ConfigError('"login timeout" must be configured!') - - return None - - -def generate(login): - # calculate users encrypted password - if 'user' in login: - for user, user_config in login['user'].items(): - tmp = dict_search('authentication.plaintext_password', user_config) - if tmp: - encrypted_password = linux_context.hash(tmp) - login['user'][user]['authentication']['encrypted_password'] = encrypted_password - del login['user'][user]['authentication']['plaintext_password'] - - # remove old plaintext password and set new encrypted password - env = os.environ.copy() - env['vyos_libexec_dir'] = directories['base'] - - # Set default commands for re-adding user with encrypted password - del_user_plain = f"system login user {user} authentication plaintext-password" - add_user_encrypt = f"system login user {user} authentication encrypted-password '{encrypted_password}'" - - lvl = env['VYATTA_EDIT_LEVEL'] - # We're in config edit level, for example "edit system login" - # Change default commands for re-adding user with encrypted password - if lvl != '/': - # Replace '/system/login' to 'system login' - lvl = lvl.strip('/').split('/') - # Convert command str to list - del_user_plain = del_user_plain.split() - # New command exclude level, for example "edit system login" - del_user_plain = del_user_plain[len(lvl):] - # Convert string to list - del_user_plain = " ".join(del_user_plain) - - add_user_encrypt = add_user_encrypt.split() - add_user_encrypt = add_user_encrypt[len(lvl):] - add_user_encrypt = " ".join(add_user_encrypt) - - ret, out = rc_cmd(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) - if ret: raise ConfigError(out) - ret, out = rc_cmd(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) - if ret: raise ConfigError(out) - else: - try: - if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): - # If the current encrypted bassword matches the encrypted password - # from the config - do not update it. This will remove the encrypted - # value from the system logs. - # - # The encrypted password will be set only once during the first boot - # after an image upgrade. - del login['user'][user]['authentication']['encrypted_password'] - except: - pass - - ### RADIUS based user authentication - if 'radius' in login: - render(radius_config_file, 'login/pam_radius_auth.conf.j2', login, - permission=0o600, user='root', group='root') - else: - if os.path.isfile(radius_config_file): - os.unlink(radius_config_file) - - ### TACACS+ based user authentication - if 'tacacs' in login: - render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login, - permission=0o644, user='root', group='root') - render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login, - permission=0o644, user='root', group='root') - else: - if os.path.isfile(tacacs_pam_config_file): - os.unlink(tacacs_pam_config_file) - if os.path.isfile(tacacs_nss_config_file): - os.unlink(tacacs_nss_config_file) - - - - # NSS must always be present on the system - render(nss_config_file, 'login/nsswitch.conf.j2', login, - permission=0o644, user='root', group='root') - - # /etc/security/limits.d/10-vyos.conf - if 'max_login_session' in login: - render(limits_file, 'login/limits.j2', login, - permission=0o644, user='root', group='root') - else: - if os.path.isfile(limits_file): - os.unlink(limits_file) - - if 'timeout' in login: - render(autologout_file, 'login/autologout.j2', login, - permission=0o755, user='root', group='root') - else: - if os.path.isfile(autologout_file): - os.unlink(autologout_file) - - return None - - -def apply(login): - enable_otp = False - if 'user' in login: - for user, user_config in login['user'].items(): - # make new user using vyatta shell and make home directory (-m), - # default group of 100 (users) - command = 'useradd --create-home --no-user-group ' - # check if user already exists: - if user in get_local_users(): - # update existing account - command = 'usermod' - - # all accounts use /bin/vbash - command += ' --shell /bin/vbash' - # we need to use '' quotes when passing formatted data to the shell - # else it will not work as some data parts are lost in translation - tmp = dict_search('authentication.encrypted_password', user_config) - if tmp: command += f" --password '{tmp}'" - - tmp = dict_search('full_name', user_config) - if tmp: command += f" --comment '{tmp}'" - - tmp = dict_search('home_directory', user_config) - if tmp: command += f" --home '{tmp}'" - else: command += f" --home '/home/{user}'" - - command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' - try: - cmd(command) - # we should not rely on the value stored in - # user_config['home_directory'], as a crazy user will choose - # username root or any other system user which will fail. - # - # XXX: Should we deny using root at all? - home_dir = getpwnam(user).pw_dir - # T5875: ensure UID is properly set on home directory if user is re-added - if os.path.exists(home_dir): - chown(home_dir, user=user, recursive=True) - - render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', - user_config, permission=0o600, - formater=lambda _: _.replace(""", '"'), - user=user, group='users') - - except Exception as e: - raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') - - # Generate 2FA/MFA One-Time-Pad configuration - if dict_search('authentication.otp.key', user_config): - enable_otp = True - render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', - user_config, permission=0o400, user=user, group='users') - else: - # delete configuration as it's not enabled for the user - if os.path.exists(f'{home_dir}/.google_authenticator'): - os.remove(f'{home_dir}/.google_authenticator') - - if 'rm_users' in login: - for user in login['rm_users']: - try: - # Disable user to prevent re-login - call(f'usermod -s /sbin/nologin {user}') - - # Logout user if he is still logged in - if user in list(set([tmp[0] for tmp in users()])): - print(f'{user} is logged in, forcing logout!') - # re-run command until user is logged out - while run(f'pkill -HUP -u {user}'): - sleep(0.250) - - # Remove user account but leave home directory in place. Re-run - # command until user is removed - userdel might return 8 as - # SSH sessions are not all yet properly cleaned away, thus we - # simply re-run the command until the account wen't away - while run(f'userdel {user}', stderr=DEVNULL): - sleep(0.250) - - except Exception as e: - raise ConfigError(f'Deleting user "{user}" raised exception: {e}') - - # Enable/disable RADIUS in PAM configuration - cmd('pam-auth-update --disable radius-mandatory radius-optional') - if 'radius' in login: - if login['radius'].get('security_mode', '') == 'mandatory': - pam_profile = 'radius-mandatory' - else: - pam_profile = 'radius-optional' - cmd(f'pam-auth-update --enable {pam_profile}') - - # Enable/disable TACACS+ in PAM configuration - cmd('pam-auth-update --disable tacplus-mandatory tacplus-optional') - if 'tacacs' in login: - if login['tacacs'].get('security_mode', '') == 'mandatory': - pam_profile = 'tacplus-mandatory' - else: - pam_profile = 'tacplus-optional' - cmd(f'pam-auth-update --enable {pam_profile}') - - # Enable/disable Google authenticator - cmd('pam-auth-update --disable mfa-google-authenticator') - if enable_otp: - cmd(f'pam-auth-update --enable mfa-google-authenticator') - - return None - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-logs.py b/src/conf_mode/system-logs.py deleted file mode 100755 index 8ad4875d4..000000000 --- a/src/conf_mode/system-logs.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021 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 . - -from sys import exit - -from vyos import ConfigError -from vyos import airbag -from vyos.config import Config -from vyos.logger import syslog -from vyos.template import render -from vyos.utils.dict import dict_search -airbag.enable() - -# path to logrotate configs -logrotate_atop_file = '/etc/logrotate.d/vyos-atop' -logrotate_rsyslog_file = '/etc/logrotate.d/vyos-rsyslog' - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['system', 'logs'] - logs_config = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return logs_config - - -def verify(logs_config): - # Nothing to verify here - pass - - -def generate(logs_config): - # get configuration for logrotate atop - logrotate_atop = dict_search('logrotate.atop', logs_config) - # generate new config file for atop - syslog.debug('Adding logrotate config for atop') - render(logrotate_atop_file, 'logs/logrotate/vyos-atop.j2', logrotate_atop) - - # get configuration for logrotate rsyslog - logrotate_rsyslog = dict_search('logrotate.messages', logs_config) - # generate new config file for rsyslog - syslog.debug('Adding logrotate config for rsyslog') - render(logrotate_rsyslog_file, 'logs/logrotate/vyos-rsyslog.j2', - logrotate_rsyslog) - - -def apply(logs_config): - # No further actions needed - pass - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-option.py b/src/conf_mode/system-option.py deleted file mode 100755 index d92121b3d..000000000 --- a/src/conf_mode/system-option.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019-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 . - -import os - -from netifaces import interfaces -from sys import exit -from time import sleep - -from vyos.config import Config -from vyos.configverify import verify_source_interface -from vyos.template import render -from vyos.utils.process import cmd -from vyos.utils.process import is_systemd_service_running -from vyos.utils.network import is_addr_assigned -from vyos.utils.network import is_intf_addr_assigned -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -curlrc_config = r'/etc/curlrc' -ssh_config = r'/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf' -systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target' -time_format_to_locale = { - '12-hour': 'en_US.UTF-8', - '24-hour': 'en_GB.UTF-8' -} - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'option'] - options = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return options - -def verify(options): - if 'http_client' in options: - config = options['http_client'] - if 'source_interface' in config: - if not config['source_interface'] in interfaces(): - raise ConfigError(f'Source interface {source_interface} does not ' - f'exist'.format(**config)) - - if {'source_address', 'source_interface'} <= set(config): - raise ConfigError('Can not define both HTTP source-interface and source-address') - - if 'source_address' in config: - if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with give address specified!') - - if 'ssh_client' in options: - config = options['ssh_client'] - if 'source_address' in config: - address = config['source_address'] - if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with address "{address}" configured!') - - if 'source_interface' in config: - verify_source_interface(config) - if 'source_address' in config: - address = config['source_address'] - interface = config['source_interface'] - if not is_intf_addr_assigned(interface, address): - raise ConfigError(f'Address "{address}" not assigned on interface "{interface}"!') - - return None - -def generate(options): - render(curlrc_config, 'system/curlrc.j2', options) - render(ssh_config, 'system/ssh_config.j2', options) - return None - -def apply(options): - # System bootup beep - if 'startup_beep' in options: - cmd('systemctl enable vyos-beep.service') - else: - cmd('systemctl disable vyos-beep.service') - - # Ctrl-Alt-Delete action - if os.path.exists(systemd_action_file): - os.unlink(systemd_action_file) - if 'ctrl_alt_delete' in options: - if options['ctrl_alt_delete'] == 'reboot': - os.symlink('/lib/systemd/system/reboot.target', systemd_action_file) - elif options['ctrl_alt_delete'] == 'poweroff': - os.symlink('/lib/systemd/system/poweroff.target', systemd_action_file) - - # Configure HTTP client - if 'http_client' not in options: - if os.path.exists(curlrc_config): - os.unlink(curlrc_config) - - # Configure SSH client - if 'ssh_client' not in options: - if os.path.exists(ssh_config): - os.unlink(ssh_config) - - # Reboot system on kernel panic - timeout = '0' - if 'reboot_on_panic' in options: - timeout = '60' - with open('/proc/sys/kernel/panic', 'w') as f: - f.write(timeout) - - # tuned - performance tuning - if 'performance' in options: - cmd('systemctl restart tuned.service') - # wait until daemon has started before sending configuration - while (not is_systemd_service_running('tuned.service')): - sleep(0.250) - cmd('tuned-adm profile network-{performance}'.format(**options)) - else: - cmd('systemctl stop tuned.service') - - # Keyboard layout - there will be always the default key inside the dict - # but we check for key existence anyway - if 'keyboard_layout' in options: - cmd('loadkeys {keyboard_layout}'.format(**options)) - - # Enable/diable root-partition-auto-resize SystemD service - if 'root_partition_auto_resize' in options: - cmd('systemctl enable root-partition-auto-resize.service') - else: - cmd('systemctl disable root-partition-auto-resize.service') - - # Time format 12|24-hour - if 'time_format' in options: - time_format = time_format_to_locale.get(options['time_format']) - cmd(f'localectl set-locale LC_TIME={time_format}') - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-proxy.py b/src/conf_mode/system-proxy.py deleted file mode 100755 index 079c43e7e..000000000 --- a/src/conf_mode/system-proxy.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2022 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 . - -import os - -from sys import exit - -from vyos.config import Config -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -proxy_def = r'/etc/profile.d/vyos-system-proxy.sh' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'proxy'] - if not conf.exists(base): - return None - - proxy = conf.get_config_dict(base, get_first_key=True) - return proxy - -def verify(proxy): - if not proxy: - return - - if 'url' not in proxy or 'port' not in proxy: - raise ConfigError('Proxy URL and port require a value') - - if ('username' in proxy and 'password' not in proxy) or \ - ('username' not in proxy and 'password' in proxy): - raise ConfigError('Both username and password need to be defined!') - -def generate(proxy): - if not proxy: - if os.path.isfile(proxy_def): - os.unlink(proxy_def) - return - - render(proxy_def, 'system/proxy.j2', proxy, permission=0o755) - -def apply(proxy): - pass - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-syslog.py b/src/conf_mode/system-syslog.py deleted file mode 100755 index 07fbb0734..000000000 --- a/src/conf_mode/system-syslog.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 VyOS maintainers and contributors -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 or later as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -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.utils.process import call -from vyos.template import render -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf' -logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' -systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['system', 'syslog'] - if not conf.exists(base): - return None - - syslog = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - syslog.update({ 'logrotate' : logrotate_conf }) - - 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'] - - return syslog - -def verify(syslog): - if not syslog: - return None - - verify_vrf(syslog) - -def generate(syslog): - if not syslog: - if os.path.exists(rsyslog_conf): - os.unlink(rsyslog_conf) - if os.path.exists(logrotate_conf): - os.unlink(logrotate_conf) - - return None - - 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}') - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/system-timezone.py b/src/conf_mode/system-timezone.py deleted file mode 100755 index cd3d4b229..000000000 --- a/src/conf_mode/system-timezone.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2019 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 . - -import sys -import os - -from copy import deepcopy -from vyos.config import Config -from vyos import ConfigError -from vyos.utils.process import call - -from vyos import airbag -airbag.enable() - -default_config_data = { - 'name': 'UTC' -} - -def get_config(config=None): - tz = deepcopy(default_config_data) - if config: - conf = config - else: - conf = Config() - if conf.exists('system time-zone'): - tz['name'] = conf.return_value('system time-zone') - - return tz - -def verify(tz): - pass - -def generate(tz): - pass - -def apply(tz): - call('/usr/bin/timedatectl set-timezone {}'.format(tz['name'])) - call('systemctl restart rsyslog') - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/system_acceleration.py b/src/conf_mode/system_acceleration.py new file mode 100755 index 000000000..e4b248675 --- /dev/null +++ b/src/conf_mode/system_acceleration.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os +import re + +from sys import exit + +from vyos.config import Config +from vyos.utils.process import popen +from vyos.utils.process import run +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +qat_init_script = '/etc/init.d/qat_service' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + data = {} + + if conf.exists(['system', 'acceleration', 'qat']): + data.update({'qat_enable' : ''}) + + if conf.exists(['vpn', 'ipsec']): + data.update({'ipsec' : ''}) + + if conf.exists(['interfaces', 'openvpn']): + data.update({'openvpn' : ''}) + + return data + + +def vpn_control(action, force_ipsec=False): + # XXX: Should these commands report failure? + if action == 'restore' and force_ipsec: + return run('ipsec start') + + return run(f'ipsec {action}') + + +def verify(qat): + if 'qat_enable' not in qat: + return + + # Check if QAT service installed + if not os.path.exists(qat_init_script): + raise ConfigError('QAT init script not found') + + # Check if QAT device exist + output, err = popen('lspci -nn', decode='utf-8') + if not err: + # PCI id | Chipset + # 19e2 -> C3xx + # 37c8 -> C62x + # 0435 -> DH895 + # 6f54 -> D15xx + # 18ee -> QAT_200XX + data = re.findall( + '(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)|(8086:18ee)', output) + # If QAT devices found + if not data: + raise ConfigError('No QAT acceleration device found') + +def apply(qat): + # Shutdown VPN service which can use QAT + if 'ipsec' in qat: + vpn_control('stop') + + # Enable/Disable QAT service + if 'qat_enable' in qat: + run(f'{qat_init_script} start') + else: + run(f'{qat_init_script} stop') + + # Recover VPN service + if 'ipsec' in qat: + vpn_control('start') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + apply(c) + except ConfigError as e: + print(e) + vpn_control('restore', force_ipsec=('ipsec' in c)) + exit(1) diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py new file mode 100755 index 000000000..c681a8405 --- /dev/null +++ b/src/conf_mode/system_config-management.py @@ -0,0 +1,96 @@ +#!/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 . + +import os +import sys + +from vyos import ConfigError +from vyos.config import Config +from vyos.config_mgmt import ConfigMgmt +from vyos.config_mgmt import commit_post_hook_dir, commit_hooks + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['system', 'config-management'] + if not conf.exists(base): + return None + + mgmt = ConfigMgmt(config=conf) + + return mgmt + +def verify(_mgmt): + return + +def generate(mgmt): + if mgmt is None: + return + + mgmt.initialize_revision() + +def apply(mgmt): + if mgmt is None: + return + + locations = mgmt.locations + archive_target = os.path.join(commit_post_hook_dir, + commit_hooks['commit_archive']) + if locations: + try: + os.symlink('/usr/bin/config-mgmt', archive_target) + except FileExistsError: + pass + except OSError as exc: + raise ConfigError from exc + else: + try: + os.unlink(archive_target) + except FileNotFoundError: + pass + except OSError as exc: + raise ConfigError from exc + + revisions = mgmt.max_revisions + revision_target = os.path.join(commit_post_hook_dir, + commit_hooks['commit_revision']) + if revisions > 0: + try: + os.symlink('/usr/bin/config-mgmt', revision_target) + except FileExistsError: + pass + except OSError as exc: + raise ConfigError from exc + else: + try: + os.unlink(revision_target) + except FileNotFoundError: + pass + except OSError as exc: + raise ConfigError from exc + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py new file mode 100755 index 000000000..7f6c71440 --- /dev/null +++ b/src/conf_mode/system_conntrack.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import re + +from sys import exit + +from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents +from vyos.utils.process import process_named_running +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.utils.process import run +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' +sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' +nftables_ct_file = r'/run/nftables-ct.conf' + +# Every ALG (Application Layer Gateway) consists of either a Kernel Object +# also called a Kernel Module/Driver or some rules present in iptables +module_map = { + 'ftp': { + 'ko': ['nf_nat_ftp', 'nf_conntrack_ftp'], + 'nftables': ['ct helper set "ftp_tcp" tcp dport {21} return'] + }, + 'h323': { + 'ko': ['nf_nat_h323', 'nf_conntrack_h323'], + 'nftables': ['ct helper set "ras_udp" udp dport {1719} return', + 'ct helper set "q931_tcp" tcp dport {1720} return'] + }, + 'nfs': { + 'nftables': ['ct helper set "rpc_tcp" tcp dport {111} return', + 'ct helper set "rpc_udp" udp dport {111} return'] + }, + 'pptp': { + 'ko': ['nf_nat_pptp', 'nf_conntrack_pptp'], + 'nftables': ['ct helper set "pptp_tcp" tcp dport {1723} return'], + 'ipv4': True + }, + 'sip': { + 'ko': ['nf_nat_sip', 'nf_conntrack_sip'], + 'nftables': ['ct helper set "sip_tcp" tcp dport {5060,5061} return', + 'ct helper set "sip_udp" udp dport {5060,5061} return'] + }, + 'sqlnet': { + 'nftables': ['ct helper set "tns_tcp" tcp dport {1521,1525,1536} return'] + }, + 'tftp': { + 'ko': ['nf_nat_tftp', 'nf_conntrack_tftp'], + 'nftables': ['ct helper set "tftp_udp" udp dport {69} return'] + }, +} + +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group' +] + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'conntrack'] + + conntrack = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + conntrack['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + conntrack['ipv4_nat_action'] = 'accept' if conf.exists(['nat']) else 'return' + conntrack['ipv6_nat_action'] = 'accept' if conf.exists(['nat66']) else 'return' + conntrack['wlb_action'] = 'accept' if conf.exists(['load-balancing', 'wan']) else 'return' + conntrack['wlb_local_action'] = conf.exists(['load-balancing', 'wan', 'enable-local-traffic']) + + conntrack['module_map'] = module_map + + if conf.exists(['service', 'conntrack-sync']): + set_dependents('conntrack_sync', conf) + + return conntrack + +def verify(conntrack): + for inet in ['ipv4', 'ipv6']: + if dict_search_args(conntrack, 'ignore', inet, 'rule') != None: + for rule, rule_config in conntrack['ignore'][inet]['rule'].items(): + if dict_search('destination.port', rule_config) or \ + dict_search('destination.group.port_group', rule_config) or \ + dict_search('source.port', rule_config) or \ + dict_search('source.group.port_group', rule_config): + if 'protocol' not in rule_config or rule_config['protocol'] not in ['tcp', 'udp']: + raise ConfigError(f'Port requires tcp or udp as protocol in rule {rule}') + + tcp_flags = dict_search_args(rule_config, 'tcp', 'flags') + if tcp_flags: + if dict_search_args(rule_config, 'protocol') != 'tcp': + raise ConfigError('Protocol must be tcp when specifying tcp flags') + + not_flags = dict_search_args(rule_config, 'tcp', 'flags', 'not') + if not_flags: + duplicates = [flag for flag in tcp_flags if flag in not_flags] + if duplicates: + raise ConfigError(f'Cannot match a tcp flag as set and not set') + + for side in ['destination', 'source']: + if side in rule_config: + side_conf = rule_config[side] + + if 'group' in side_conf: + if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group or domain-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + error_group = group.replace("_", "-") + + if group in ['address_group', 'network_group', 'domain_group']: + if 'address' in side_conf: + raise ConfigError(f'{error_group} and address cannot both be defined') + + if group_name and group_name[0] == '!': + group_name = group_name[1:] + + if inet == 'ipv6': + group = f'ipv6_{group}' + + group_obj = dict_search_args(conntrack['firewall'], 'group', group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on ignore rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members!') + + if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: + for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): + if 'protocol' not in rule_config: + raise ConfigError(f'Conntrack custom timeout rule {rule} requires protocol tcp or udp') + else: + if 'tcp' in rule_config['protocol'] and 'udp' in rule_config['protocol']: + raise ConfigError(f'conntrack custom timeout rule {rule} - Cant use both tcp and udp protocol') + return None + +def generate(conntrack): + if not os.path.exists(nftables_ct_file): + conntrack['first_install'] = True + + # Determine if conntrack is needed + conntrack['ipv4_firewall_action'] = 'return' + conntrack['ipv6_firewall_action'] = 'return' + + for rules, path in dict_search_recursive(conntrack['firewall'], 'rule'): + if any(('state' in rule_conf or 'connection_status' in rule_conf or 'offload_target' in rule_conf) for rule_conf in rules.values()): + if path[0] == 'ipv4': + conntrack['ipv4_firewall_action'] = 'accept' + elif path[0] == 'ipv6': + conntrack['ipv6_firewall_action'] = 'accept' + + render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) + render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) + render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) + return None + +def apply(conntrack): + # Depending on the enable/disable state of the ALG (Application Layer Gateway) + # modules we need to either insmod or rmmod the helpers. + + add_modules = [] + rm_modules = [] + + for module, module_config in module_map.items(): + if dict_search_args(conntrack, 'modules', module) is None: + if 'ko' in module_config: + unloaded = [mod for mod in module_config['ko'] if os.path.exists(f'/sys/module/{mod}')] + rm_modules.extend(unloaded) + else: + if 'ko' in module_config: + add_modules.extend(module_config['ko']) + + # Add modules before nftables uses them + if add_modules: + module_str = ' '.join(add_modules) + cmd(f'modprobe -a {module_str}') + + # Load new nftables ruleset + install_result, output = rc_cmd(f'nft -f {nftables_ct_file}') + if install_result == 1: + raise ConfigError(f'Failed to apply configuration: {output}') + + # Remove modules after nftables stops using them + if rm_modules: + module_str = ' '.join(rm_modules) + cmd(f'rmmod {module_str}') + + try: + call_dependents() + except ConfigError: + # Ignore config errors on dependent due to being called too early. Example: + # ConfigError("ConfigError('Interface ethN requires an IP address!')") + pass + + # We silently ignore all errors + # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080 + cmd(f'sysctl -f {sysctl_file}') + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py new file mode 100755 index 000000000..206f513c8 --- /dev/null +++ b/src/conf_mode/system_flow-accounting.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import re + +from sys import exit +from ipaddress import ip_address + +from vyos.base import Warning +from vyos.config import Config +from vyos.config import config_dict_merge +from vyos.configverify import verify_vrf +from vyos.ifconfig import Section +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.utils.network import is_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +uacctd_conf_path = '/run/pmacct/uacctd.conf' +systemd_service = 'uacctd.service' +systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' +nftables_nflog_table = 'raw' +nftables_nflog_chain = 'VYOS_PREROUTING_HOOK' +egress_nftables_nflog_table = 'inet mangle' +egress_nftables_nflog_chain = 'FORWARD' + +# get nftables rule dict for chain in table +def _nftables_get_nflog(chain, table): + # define list with rules + rules = [] + + # prepare regex for parsing rules + rule_pattern = '[io]ifname "(?P[\w\.\*\-]+)".*handle (?P[\d]+)' + rule_re = re.compile(rule_pattern) + + # run nftables, save output and split it by lines + nftables_command = f'nft -a list chain {table} {chain}' + tmp = cmd(nftables_command, message='Failed to get flows list') + # parse each line and add information to list + for current_rule in tmp.splitlines(): + if 'FLOW_ACCOUNTING_RULE' not in current_rule: + continue + current_rule_parsed = rule_re.search(current_rule) + if current_rule_parsed: + groups = current_rule_parsed.groupdict() + rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] }) + + # return list with rules + return rules + +def _nftables_config(configured_ifaces, direction, length=None): + # define list of nftables commands to modify settings + nftable_commands = [] + nftables_chain = nftables_nflog_chain + nftables_table = nftables_nflog_table + + if direction == "egress": + nftables_chain = egress_nftables_nflog_chain + nftables_table = egress_nftables_nflog_table + + # prepare extended list with configured interfaces + configured_ifaces_extended = [] + for iface in configured_ifaces: + configured_ifaces_extended.append({ 'iface': iface }) + + # get currently configured interfaces with nftables rules + active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table) + + # compare current active list with configured one and delete excessive interfaces, add missed + active_nflog_ifaces = [] + for rule in active_nflog_rules: + interface = rule['interface'] + if interface not in configured_ifaces: + table = rule['table'] + handle = rule['handle'] + nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}') + else: + active_nflog_ifaces.append({ + 'iface': interface, + }) + + # do not create new rules for already configured interfaces + for iface in active_nflog_ifaces: + if iface in active_nflog_ifaces and iface in configured_ifaces_extended: + configured_ifaces_extended.remove(iface) + + # create missed rules + for iface_extended in configured_ifaces_extended: + iface = iface_extended['iface'] + iface_prefix = "o" if direction == "egress" else "i" + rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' + nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') + # Also add IPv6 ingres logging + if nftables_table == nftables_nflog_table: + nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') + + # change nftables + for command in nftable_commands: + cmd(command, raising=ConfigError) + + +def _nftables_trigger_setup(operation: str) -> None: + """Add a dummy rule to unlock the main pmacct loop with a packet-trigger + + Args: + operation (str): 'add' or 'delete' a trigger + """ + # check if a chain exists + table_exists = False + if run('nft -snj list table ip pmacct') == 0: + table_exists = True + + if operation == 'delete' and table_exists: + nft_cmd: str = 'nft delete table ip pmacct' + cmd(nft_cmd, raising=ConfigError) + if operation == 'add' and not table_exists: + nft_cmds: list[str] = [ + 'nft add table ip pmacct', + 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }', + 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER' + ] + for nft_cmd in nft_cmds: + cmd(nft_cmd, raising=ConfigError) + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'flow-accounting'] + if not conf.exists(base): + return None + + flow_accounting = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + # We have gathered the dict representation of the CLI, but there are + # default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**flow_accounting.kwargs, + recursive=True) + + # 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_accounting = config_dict_merge(default_values, flow_accounting) + + return flow_accounting + +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, ' \ + 'or not set "disable-imt" for flow-accounting!') + + # Check if at least one interface is configured + if 'interface' not in flow_config: + raise ConfigError('Flow accounting requires at least one interface to ' \ + 'be configured!') + + # check that all configured interfaces exists in the system + for interface in flow_config['interface']: + if interface not in Section.interfaces(): + # Changed from error to warning to allow adding dynamic interfaces + # and interface templates + Warning(f'Interface "{interface}" is not presented in the system') + + # 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!') + + # check NetFlow configuration + if 'netflow' in flow_config: + # check if vrf is defined for netflow + netflow_vrf = None + if 'vrf' in flow_config: + netflow_vrf = flow_config['vrf'] + + # check if at least one NetFlow collector is configured if NetFlow configuration is presented + if 'server' not in flow_config['netflow']: + raise ConfigError('You need to configure at least one NetFlow server!') + + # Check if configured netflow source-address exist in the system + if 'source_address' in flow_config['netflow']: + if not is_addr_assigned(flow_config['netflow']['source_address'], netflow_vrf): + tmp = flow_config['netflow']['source_address'] + raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') + + # Check if engine-id compatible with selected protocol version + if 'engine_id' in flow_config['netflow']: + v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' + v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' + engine_id = flow_config['netflow']['engine_id'] + version = flow_config['netflow']['version'] + + if flow_config['netflow']['version'] == '5': + regex_filter = re.compile(v5_filter) + if not regex_filter.search(engine_id): + raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\ + f'together with NetFlow protocol version "{version}"!') + else: + regex_filter = re.compile(v9v10_filter) + if not regex_filter.search(flow_config['netflow']['engine_id']): + raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\ + f'with NetFlow protocol version "{version}"!') + + # return True if all checks were passed + return True + +def generate(flow_config): + if not flow_config: + return None + + render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config) + render(systemd_override, 'pmacct/override.conf.j2', flow_config) + # Reload systemd manager configuration + call('systemctl daemon-reload') + +def apply(flow_config): + # Check if flow-accounting was removed and define command + if not flow_config: + _nftables_config([], 'ingress') + _nftables_config([], 'egress') + + # Stop flow-accounting daemon and remove configuration file + call(f'systemctl stop {systemd_service}') + if os.path.exists(uacctd_conf_path): + os.unlink(uacctd_conf_path) + + # must be done after systemctl + _nftables_trigger_setup('delete') + + return + + # Start/reload flow-accounting daemon + call(f'systemctl restart {systemd_service}') + + # configure nftables rules for defined interfaces + if 'interface' in flow_config: + _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length']) + + # configure egress the same way if configured otherwise remove it + if 'enable_egress' in flow_config: + _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length']) + else: + _nftables_config([], 'egress') + + # add a trigger for signal processing + _nftables_trigger_setup('add') + + +if __name__ == '__main__': + try: + config = get_config() + verify(config) + generate(config) + apply(config) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py new file mode 100755 index 000000000..6204cf247 --- /dev/null +++ b/src/conf_mode/system_host-name.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import re +import sys +import copy + +import vyos.hostsd_client + +from vyos.base import Warning +from vyos.config import Config +from vyos.ifconfig import Section +from vyos.template import is_ip +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import process_named_running +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +default_config_data = { + 'hostname': 'vyos', + 'domain_name': '', + 'domain_search': [], + 'nameserver': [], + 'nameservers_dhcp_interfaces': {}, + 'static_host_mapping': {} +} + +hostsd_tag = 'system' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + hosts = copy.deepcopy(default_config_data) + + hosts['hostname'] = conf.return_value(['system', 'host-name']) + + # This may happen if the config is not loaded yet, + # e.g. if run by cloud-init + if not hosts['hostname']: + hosts['hostname'] = default_config_data['hostname'] + + if conf.exists(['system', 'domain-name']): + hosts['domain_name'] = conf.return_value(['system', 'domain-name']) + hosts['domain_search'].append(hosts['domain_name']) + + if conf.exists(['system', 'domain-search']): + for search in conf.return_values(['system', 'domain-search']): + hosts['domain_search'].append(search) + + if conf.exists(['system', 'name-server']): + for ns in conf.return_values(['system', 'name-server']): + if is_ip(ns): + hosts['nameserver'].append(ns) + else: + tmp = '' + if_type = Section.section(ns) + if conf.exists(['interfaces', if_type, ns, 'address']): + tmp = conf.return_values(['interfaces', if_type, ns, 'address']) + + hosts['nameservers_dhcp_interfaces'].update({ ns : tmp }) + + # system static-host-mapping + for hn in conf.list_nodes(['system', 'static-host-mapping', 'host-name']): + hosts['static_host_mapping'][hn] = {} + hosts['static_host_mapping'][hn]['address'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'inet']) + hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'alias']) + + return hosts + + +def verify(hosts): + if hosts is None: + return None + + # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)" + hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$") + if not hostname_regex.match(hosts['hostname']): + raise ConfigError('Invalid host name ' + hosts["hostname"]) + + # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length" + length = len(hosts['hostname']) + if length < 1 or length > 63: + raise ConfigError( + 'Invalid host-name length, must be less than 63 characters') + + all_static_host_mapping_addresses = [] + # static mappings alias hostname + for host, hostprops in hosts['static_host_mapping'].items(): + if not hostprops['address']: + raise ConfigError(f'IP address required for static-host-mapping "{host}"') + all_static_host_mapping_addresses.append(hostprops['address']) + for a in hostprops['aliases']: + if not hostname_regex.match(a) and len(a) != 0: + raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') + + for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items(): + # Warnin user if interface does not have DHCP or DHCPv6 configured + if not set(interface_config).intersection(['dhcp', 'dhcpv6']): + Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!') + + return None + + +def generate(config): + pass + +def apply(config): + if config is None: + return None + + ## Send the updated data to vyos-hostsd + try: + hc = vyos.hostsd_client.Client() + + hc.set_host_name(config['hostname'], config['domain_name']) + + hc.delete_search_domains([hostsd_tag]) + if config['domain_search']: + hc.add_search_domains({hostsd_tag: config['domain_search']}) + + hc.delete_name_servers([hostsd_tag]) + if config['nameserver']: + hc.add_name_servers({hostsd_tag: config['nameserver']}) + + # add our own tag's (system) nameservers and search to resolv.conf + hc.delete_name_server_tags_system(hc.get_name_server_tags_system()) + hc.add_name_server_tags_system([hostsd_tag]) + + # this will add the dhcp client nameservers to resolv.conf + for intf in config['nameservers_dhcp_interfaces']: + hc.add_name_server_tags_system([f'dhcp-{intf}', f'dhcpv6-{intf}']) + + hc.delete_hosts([hostsd_tag]) + if config['static_host_mapping']: + hc.add_hosts({hostsd_tag: config['static_host_mapping']}) + + hc.apply() + except vyos.hostsd_client.VyOSHostsdError as e: + raise ConfigError(str(e)) + + ## Actually update the hostname -- vyos-hostsd doesn't do that + + # No domain name -- the Debian way. + hostname_new = config['hostname'] + + # rsyslog runs into a race condition at boot time with systemd + # restart rsyslog only if the hostname changed. + hostname_old = cmd('hostnamectl --static') + call(f'hostnamectl set-hostname --static {hostname_new}') + + # Restart services that use the hostname + if hostname_new != hostname_old: + call("systemctl restart rsyslog.service") + + # If SNMP is running, restart it too + if process_named_running('snmpd'): + call('systemctl restart snmpd.service') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py new file mode 100755 index 000000000..7612e2c0d --- /dev/null +++ b/src/conf_mode/system_ip.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configverify import verify_route_map +from vyos.template import render_to_string +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active +from vyos.utils.system import sysctl_write + +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() + 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) + return opt + +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 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)) + # Amount upon reaching which the records begin to be cleared immediately + sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) + # Amount after which the records begin to be cleaned after 5 seconds + sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) + # 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) + + # enable/disable IPv4 directed broadcast forwarding + tmp = dict_search('disable_directed_broadcast', opt) + value = '0' if (tmp != None) else '1' + write_file('/proc/sys/net/ipv4/conf/all/bc_forwarding', value) + + # configure multipath + tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv4.fib_multipath_use_neigh', value) + + tmp = dict_search('multipath.layer4_hashing', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv4.fib_multipath_hash_policy', value) + + # configure TCP options (defaults as of Linux 6.4) + tmp = dict_search('tcp.mss.probing', opt) + if tmp is None: + value = 0 + elif tmp == 'on-icmp-black-hole': + value = 1 + elif tmp == 'force': + value = 2 + else: + # Shouldn't happen + raise ValueError("TCP MSS probing is neither 'on-icmp-black-hole' nor 'force'!") + sysctl_write('net.ipv4.tcp_mtu_probing', value) + + tmp = dict_search('tcp.mss.base', opt) + value = '1024' if (tmp is None) else tmp + sysctl_write('net.ipv4.tcp_base_mss', value) + + tmp = dict_search('tcp.mss.floor', opt) + value = '48' if (tmp is None) else tmp + sysctl_write('net.ipv4.tcp_mtu_probe_floor', value) + + # During startup of vyos-router that brings up FRR, the service is not yet + # running when this script is called first. Skip this part and wait for initial + # 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'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 __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_ipv6.py b/src/conf_mode/system_ipv6.py new file mode 100755 index 000000000..90a1a8087 --- /dev/null +++ b/src/conf_mode/system_ipv6.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os + +from sys import exit +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.configverify import verify_route_map +from vyos.template import render_to_string +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.system import sysctl_write +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() + 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) + return opt + +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 apply(opt): + # configure multipath + tmp = dict_search('multipath.layer4_hashing', opt) + value = '1' if (tmp != None) else '0' + sysctl_write('net.ipv6.fib_multipath_hash_policy', value) + + # Apply ND threshold values + # table_size has a default value - thus the key always exists + size = int(dict_search('neighbor.table_size', opt)) + # Amount upon reaching which the records begin to be cleared immediately + sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) + # Amount after which the records begin to be cleaned after 5 seconds + sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) + # Minimum number of stored records is indicated which is not cleared + sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) + + # 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' + for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'): + for name in files: + if name == 'accept_dad': + write_file(os.path.join(root, name), value) + + # During startup of vyos-router that brings up FRR, the service is not yet + # running when this script is called first. Skip this part and wait for initial + # 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'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 __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py new file mode 100755 index 000000000..f34575aff --- /dev/null +++ b/src/conf_mode/system_login.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-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 . + +import os + +from passlib.hosts import linux_context +from psutil import users +from pwd import getpwall +from pwd import getpwnam +from sys import exit +from time import sleep + +from vyos.config import Config +from vyos.configverify import verify_vrf +from vyos.defaults import directories +from vyos.template import render +from vyos.template import is_ipv4 +from vyos.utils.dict import dict_search +from vyos.utils.file import chown +from vyos.utils.process import cmd +from vyos.utils.process import call +from vyos.utils.process import rc_cmd +from vyos.utils.process import run +from vyos.utils.process import DEVNULL +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +autologout_file = "/etc/profile.d/autologout.sh" +limits_file = "/etc/security/limits.d/10-vyos.conf" +radius_config_file = "/etc/pam_radius_auth.conf" +tacacs_pam_config_file = "/etc/tacplus_servers" +tacacs_nss_config_file = "/etc/tacplus_nss.conf" +nss_config_file = "/etc/nsswitch.conf" + +# Minimum UID used when adding system users +MIN_USER_UID: int = 1000 +# Maximim UID used when adding system users +MAX_USER_UID: int = 59999 +# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec +MAX_RADIUS_TIMEOUT: int = 50 +# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout) +MAX_RADIUS_COUNT: int = 8 +# Maximum number of supported TACACS servers +MAX_TACACS_COUNT: int = 8 + +# 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(): + """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: + continue + if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID: + continue + if s_user.pw_name in SYSTEM_USER_SKIP_LIST: + continue + local_users.append(s_user.pw_name) + + return local_users + +def get_shadow_password(username): + with open('/etc/shadow') as f: + for user in f.readlines(): + items = user.split(":") + if username == items[0]: + return items[1] + return None + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'login'] + login = conf.get_config_dict(base, key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + + # users no longer existing in the running configuration need to be deleted + local_users = get_local_users() + cli_users = [] + if 'user' in login: + cli_users = list(login['user']) + + # prune TACACS global defaults if not set by user + if login.from_defaults(['tacacs']): + del login['tacacs'] + # same for RADIUS + if login.from_defaults(['radius']): + del login['radius'] + + # create a list of all users, cli and users + all_users = list(set(local_users + cli_users)) + # We will remove any normal users that dos not exist in the current + # configuration. This can happen if user is added but configuration was not + # saved and system is rebooted. + rm_users = [tmp for tmp in all_users if tmp not in cli_users] + if rm_users: login.update({'rm_users' : rm_users}) + + return login + +def verify(login): + if 'rm_users' in login: + # This check is required as the script is also executed from vyos-router + # init script and there is no SUDO_USER environment variable available + # during system boot. + if 'SUDO_USER' in os.environ: + cur_user = os.environ['SUDO_USER'] + if cur_user in login['rm_users']: + raise ConfigError(f'Attempting to delete current user: {cur_user}') + + if 'user' in login: + system_users = getpwall() + for user, user_config in login['user'].items(): + # Linux system users range up until UID 1000, we can not create a + # VyOS CLI user which already exists as system user + for s_user in system_users: + 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!') + + 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}"!') + if 'key' not in pubkey_options: + raise ConfigError(f'Missing key for public-key "{pubkey}"!') + + if {'radius', 'tacacs'} <= set(login): + raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') + + # At lease one RADIUS server must not be disabled + if 'radius' in login: + if 'server' not in login['radius']: + raise ConfigError('No RADIUS server defined!') + sum_timeout: int = 0 + radius_servers_count: int = 0 + fail = True + for server, server_config in dict_search('radius.server', login).items(): + if 'key' not in server_config: + raise ConfigError(f'RADIUS server "{server}" requires key!') + if 'disable' not in server_config: + sum_timeout += int(server_config['timeout']) + radius_servers_count += 1 + fail = False + + if fail: + raise ConfigError('All RADIUS servers are disabled') + + if radius_servers_count > MAX_RADIUS_COUNT: + raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!') + + if sum_timeout > MAX_RADIUS_TIMEOUT: + raise ConfigError('Sum of RADIUS servers timeouts ' + 'has to be less or eq 50 sec') + + verify_vrf(login['radius']) + + if 'source_address' in login['radius']: + ipv4_count = 0 + ipv6_count = 0 + for address in login['radius']['source_address']: + if is_ipv4(address): ipv4_count += 1 + else: ipv6_count += 1 + + if ipv4_count > 1: + raise ConfigError('Only one IPv4 source-address can be set!') + if ipv6_count > 1: + raise ConfigError('Only one IPv6 source-address can be set!') + + if 'tacacs' in login: + tacacs_servers_count: int = 0 + fail = True + for server, server_config in dict_search('tacacs.server', login).items(): + if 'key' not in server_config: + raise ConfigError(f'TACACS server "{server}" requires key!') + if 'disable' not in server_config: + tacacs_servers_count += 1 + fail = False + + if fail: + raise ConfigError('All RADIUS servers are disabled') + + if tacacs_servers_count > MAX_TACACS_COUNT: + raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') + + verify_vrf(login['tacacs']) + + if 'max_login_session' in login and 'timeout' not in login: + raise ConfigError('"login timeout" must be configured!') + + return None + + +def generate(login): + # calculate users encrypted password + if 'user' in login: + for user, user_config in login['user'].items(): + tmp = dict_search('authentication.plaintext_password', user_config) + if tmp: + encrypted_password = linux_context.hash(tmp) + login['user'][user]['authentication']['encrypted_password'] = encrypted_password + del login['user'][user]['authentication']['plaintext_password'] + + # remove old plaintext password and set new encrypted password + env = os.environ.copy() + env['vyos_libexec_dir'] = directories['base'] + + # Set default commands for re-adding user with encrypted password + del_user_plain = f"system login user {user} authentication plaintext-password" + add_user_encrypt = f"system login user {user} authentication encrypted-password '{encrypted_password}'" + + lvl = env['VYATTA_EDIT_LEVEL'] + # We're in config edit level, for example "edit system login" + # Change default commands for re-adding user with encrypted password + if lvl != '/': + # Replace '/system/login' to 'system login' + lvl = lvl.strip('/').split('/') + # Convert command str to list + del_user_plain = del_user_plain.split() + # New command exclude level, for example "edit system login" + del_user_plain = del_user_plain[len(lvl):] + # Convert string to list + del_user_plain = " ".join(del_user_plain) + + add_user_encrypt = add_user_encrypt.split() + add_user_encrypt = add_user_encrypt[len(lvl):] + add_user_encrypt = " ".join(add_user_encrypt) + + ret, out = rc_cmd(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) + if ret: raise ConfigError(out) + ret, out = rc_cmd(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) + if ret: raise ConfigError(out) + else: + try: + if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): + # If the current encrypted bassword matches the encrypted password + # from the config - do not update it. This will remove the encrypted + # value from the system logs. + # + # The encrypted password will be set only once during the first boot + # after an image upgrade. + del login['user'][user]['authentication']['encrypted_password'] + except: + pass + + ### RADIUS based user authentication + if 'radius' in login: + render(radius_config_file, 'login/pam_radius_auth.conf.j2', login, + permission=0o600, user='root', group='root') + else: + if os.path.isfile(radius_config_file): + os.unlink(radius_config_file) + + ### TACACS+ based user authentication + if 'tacacs' in login: + render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login, + permission=0o644, user='root', group='root') + render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login, + permission=0o644, user='root', group='root') + else: + if os.path.isfile(tacacs_pam_config_file): + os.unlink(tacacs_pam_config_file) + if os.path.isfile(tacacs_nss_config_file): + os.unlink(tacacs_nss_config_file) + + + + # NSS must always be present on the system + render(nss_config_file, 'login/nsswitch.conf.j2', login, + permission=0o644, user='root', group='root') + + # /etc/security/limits.d/10-vyos.conf + if 'max_login_session' in login: + render(limits_file, 'login/limits.j2', login, + permission=0o644, user='root', group='root') + else: + if os.path.isfile(limits_file): + os.unlink(limits_file) + + if 'timeout' in login: + render(autologout_file, 'login/autologout.j2', login, + permission=0o755, user='root', group='root') + else: + if os.path.isfile(autologout_file): + os.unlink(autologout_file) + + return None + + +def apply(login): + enable_otp = False + if 'user' in login: + for user, user_config in login['user'].items(): + # make new user using vyatta shell and make home directory (-m), + # default group of 100 (users) + command = 'useradd --create-home --no-user-group ' + # check if user already exists: + if user in get_local_users(): + # update existing account + command = 'usermod' + + # all accounts use /bin/vbash + command += ' --shell /bin/vbash' + # we need to use '' quotes when passing formatted data to the shell + # else it will not work as some data parts are lost in translation + tmp = dict_search('authentication.encrypted_password', user_config) + if tmp: command += f" --password '{tmp}'" + + tmp = dict_search('full_name', user_config) + if tmp: command += f" --comment '{tmp}'" + + tmp = dict_search('home_directory', user_config) + if tmp: command += f" --home '{tmp}'" + else: command += f" --home '/home/{user}'" + + command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' + try: + cmd(command) + # we should not rely on the value stored in + # user_config['home_directory'], as a crazy user will choose + # username root or any other system user which will fail. + # + # XXX: Should we deny using root at all? + home_dir = getpwnam(user).pw_dir + # T5875: ensure UID is properly set on home directory if user is re-added + if os.path.exists(home_dir): + chown(home_dir, user=user, recursive=True) + + render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', + user_config, permission=0o600, + formater=lambda _: _.replace(""", '"'), + user=user, group='users') + + except Exception as e: + raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') + + # Generate 2FA/MFA One-Time-Pad configuration + if dict_search('authentication.otp.key', user_config): + enable_otp = True + render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + user_config, permission=0o400, user=user, group='users') + else: + # delete configuration as it's not enabled for the user + if os.path.exists(f'{home_dir}/.google_authenticator'): + os.remove(f'{home_dir}/.google_authenticator') + + if 'rm_users' in login: + for user in login['rm_users']: + try: + # Disable user to prevent re-login + call(f'usermod -s /sbin/nologin {user}') + + # Logout user if he is still logged in + if user in list(set([tmp[0] for tmp in users()])): + print(f'{user} is logged in, forcing logout!') + # re-run command until user is logged out + while run(f'pkill -HUP -u {user}'): + sleep(0.250) + + # Remove user account but leave home directory in place. Re-run + # command until user is removed - userdel might return 8 as + # SSH sessions are not all yet properly cleaned away, thus we + # simply re-run the command until the account wen't away + while run(f'userdel {user}', stderr=DEVNULL): + sleep(0.250) + + except Exception as e: + raise ConfigError(f'Deleting user "{user}" raised exception: {e}') + + # Enable/disable RADIUS in PAM configuration + cmd('pam-auth-update --disable radius-mandatory radius-optional') + if 'radius' in login: + if login['radius'].get('security_mode', '') == 'mandatory': + pam_profile = 'radius-mandatory' + else: + pam_profile = 'radius-optional' + cmd(f'pam-auth-update --enable {pam_profile}') + + # Enable/disable TACACS+ in PAM configuration + cmd('pam-auth-update --disable tacplus-mandatory tacplus-optional') + if 'tacacs' in login: + if login['tacacs'].get('security_mode', '') == 'mandatory': + pam_profile = 'tacplus-mandatory' + else: + pam_profile = 'tacplus-optional' + cmd(f'pam-auth-update --enable {pam_profile}') + + # Enable/disable Google authenticator + cmd('pam-auth-update --disable mfa-google-authenticator') + if enable_otp: + cmd(f'pam-auth-update --enable mfa-google-authenticator') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_login_banner.py b/src/conf_mode/system_login_banner.py new file mode 100755 index 000000000..65fa04417 --- /dev/null +++ b/src/conf_mode/system_login_banner.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2020-2021 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 . + +from sys import exit +from copy import deepcopy + +from vyos.config import Config +from vyos.utils.file import write_file +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +try: + with open('/usr/share/vyos/default_motd') as f: + motd = f.read() +except: + # Use an empty banner if the default banner file cannot be read + motd = "\n" + +PRELOGIN_FILE = r'/etc/issue' +PRELOGIN_NET_FILE = r'/etc/issue.net' +POSTLOGIN_FILE = r'/etc/motd' + +default_config_data = { + 'issue': 'Welcome to VyOS - \\n \\l\n\n', + 'issue_net': '', + 'motd': motd +} + +def get_config(config=None): + banner = deepcopy(default_config_data) + if config: + conf = config + else: + conf = Config() + base_level = ['system', 'login', 'banner'] + + if not conf.exists(base_level): + return banner + else: + conf.set_level(base_level) + + # Post-Login banner + if conf.exists(['post-login']): + tmp = conf.return_value(['post-login']) + # post-login banner can be empty as well + if tmp: + tmp = tmp.replace('\\n','\n') + tmp = tmp.replace('\\t','\t') + # always add newline character + tmp += '\n' + else: + tmp = '' + + banner['motd'] = tmp + + # Pre-Login banner + if conf.exists(['pre-login']): + tmp = conf.return_value(['pre-login']) + # pre-login banner can be empty as well + if tmp: + tmp = tmp.replace('\\n','\n') + tmp = tmp.replace('\\t','\t') + # always add newline character + tmp += '\n' + else: + tmp = '' + + banner['issue'] = banner['issue_net'] = tmp + + return banner + +def verify(banner): + pass + +def generate(banner): + pass + +def apply(banner): + write_file(PRELOGIN_FILE, banner['issue']) + write_file(PRELOGIN_NET_FILE, banner['issue_net']) + write_file(POSTLOGIN_FILE, banner['motd']) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_logs.py b/src/conf_mode/system_logs.py new file mode 100755 index 000000000..8ad4875d4 --- /dev/null +++ b/src/conf_mode/system_logs.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 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 . + +from sys import exit + +from vyos import ConfigError +from vyos import airbag +from vyos.config import Config +from vyos.logger import syslog +from vyos.template import render +from vyos.utils.dict import dict_search +airbag.enable() + +# path to logrotate configs +logrotate_atop_file = '/etc/logrotate.d/vyos-atop' +logrotate_rsyslog_file = '/etc/logrotate.d/vyos-rsyslog' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['system', 'logs'] + logs_config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return logs_config + + +def verify(logs_config): + # Nothing to verify here + pass + + +def generate(logs_config): + # get configuration for logrotate atop + logrotate_atop = dict_search('logrotate.atop', logs_config) + # generate new config file for atop + syslog.debug('Adding logrotate config for atop') + render(logrotate_atop_file, 'logs/logrotate/vyos-atop.j2', logrotate_atop) + + # get configuration for logrotate rsyslog + logrotate_rsyslog = dict_search('logrotate.messages', logs_config) + # generate new config file for rsyslog + syslog.debug('Adding logrotate config for rsyslog') + render(logrotate_rsyslog_file, 'logs/logrotate/vyos-rsyslog.j2', + logrotate_rsyslog) + + +def apply(logs_config): + # No further actions needed + pass + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py new file mode 100755 index 000000000..d92121b3d --- /dev/null +++ b/src/conf_mode/system_option.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 . + +import os + +from netifaces import interfaces +from sys import exit +from time import sleep + +from vyos.config import Config +from vyos.configverify import verify_source_interface +from vyos.template import render +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_running +from vyos.utils.network import is_addr_assigned +from vyos.utils.network import is_intf_addr_assigned +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +curlrc_config = r'/etc/curlrc' +ssh_config = r'/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf' +systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target' +time_format_to_locale = { + '12-hour': 'en_US.UTF-8', + '24-hour': 'en_GB.UTF-8' +} + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'option'] + options = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True) + + return options + +def verify(options): + if 'http_client' in options: + config = options['http_client'] + if 'source_interface' in config: + if not config['source_interface'] in interfaces(): + raise ConfigError(f'Source interface {source_interface} does not ' + f'exist'.format(**config)) + + if {'source_address', 'source_interface'} <= set(config): + raise ConfigError('Can not define both HTTP source-interface and source-address') + + if 'source_address' in config: + if not is_addr_assigned(config['source_address']): + raise ConfigError('No interface with give address specified!') + + if 'ssh_client' in options: + config = options['ssh_client'] + if 'source_address' in config: + address = config['source_address'] + if not is_addr_assigned(config['source_address']): + raise ConfigError('No interface with address "{address}" configured!') + + if 'source_interface' in config: + verify_source_interface(config) + if 'source_address' in config: + address = config['source_address'] + interface = config['source_interface'] + if not is_intf_addr_assigned(interface, address): + raise ConfigError(f'Address "{address}" not assigned on interface "{interface}"!') + + return None + +def generate(options): + render(curlrc_config, 'system/curlrc.j2', options) + render(ssh_config, 'system/ssh_config.j2', options) + return None + +def apply(options): + # System bootup beep + if 'startup_beep' in options: + cmd('systemctl enable vyos-beep.service') + else: + cmd('systemctl disable vyos-beep.service') + + # Ctrl-Alt-Delete action + if os.path.exists(systemd_action_file): + os.unlink(systemd_action_file) + if 'ctrl_alt_delete' in options: + if options['ctrl_alt_delete'] == 'reboot': + os.symlink('/lib/systemd/system/reboot.target', systemd_action_file) + elif options['ctrl_alt_delete'] == 'poweroff': + os.symlink('/lib/systemd/system/poweroff.target', systemd_action_file) + + # Configure HTTP client + if 'http_client' not in options: + if os.path.exists(curlrc_config): + os.unlink(curlrc_config) + + # Configure SSH client + if 'ssh_client' not in options: + if os.path.exists(ssh_config): + os.unlink(ssh_config) + + # Reboot system on kernel panic + timeout = '0' + if 'reboot_on_panic' in options: + timeout = '60' + with open('/proc/sys/kernel/panic', 'w') as f: + f.write(timeout) + + # tuned - performance tuning + if 'performance' in options: + cmd('systemctl restart tuned.service') + # wait until daemon has started before sending configuration + while (not is_systemd_service_running('tuned.service')): + sleep(0.250) + cmd('tuned-adm profile network-{performance}'.format(**options)) + else: + cmd('systemctl stop tuned.service') + + # Keyboard layout - there will be always the default key inside the dict + # but we check for key existence anyway + if 'keyboard_layout' in options: + cmd('loadkeys {keyboard_layout}'.format(**options)) + + # Enable/diable root-partition-auto-resize SystemD service + if 'root_partition_auto_resize' in options: + cmd('systemctl enable root-partition-auto-resize.service') + else: + cmd('systemctl disable root-partition-auto-resize.service') + + # Time format 12|24-hour + if 'time_format' in options: + time_format = time_format_to_locale.get(options['time_format']) + cmd(f'localectl set-locale LC_TIME={time_format}') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_proxy.py b/src/conf_mode/system_proxy.py new file mode 100755 index 000000000..079c43e7e --- /dev/null +++ b/src/conf_mode/system_proxy.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2022 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 . + +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +proxy_def = r'/etc/profile.d/vyos-system-proxy.sh' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'proxy'] + if not conf.exists(base): + return None + + proxy = conf.get_config_dict(base, get_first_key=True) + return proxy + +def verify(proxy): + if not proxy: + return + + if 'url' not in proxy or 'port' not in proxy: + raise ConfigError('Proxy URL and port require a value') + + if ('username' in proxy and 'password' not in proxy) or \ + ('username' not in proxy and 'password' in proxy): + raise ConfigError('Both username and password need to be defined!') + +def generate(proxy): + if not proxy: + if os.path.isfile(proxy_def): + os.unlink(proxy_def) + return + + render(proxy_def, 'system/proxy.j2', proxy, permission=0o755) + +def apply(proxy): + pass + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py new file mode 100755 index 000000000..07fbb0734 --- /dev/null +++ b/src/conf_mode/system_syslog.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +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.utils.process import call +from vyos.template import render +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf' +logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' +systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'syslog'] + if not conf.exists(base): + return None + + syslog = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + syslog.update({ 'logrotate' : logrotate_conf }) + + 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'] + + return syslog + +def verify(syslog): + if not syslog: + return None + + verify_vrf(syslog) + +def generate(syslog): + if not syslog: + if os.path.exists(rsyslog_conf): + os.unlink(rsyslog_conf) + if os.path.exists(logrotate_conf): + os.unlink(logrotate_conf) + + return None + + 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}') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_task-scheduler.py b/src/conf_mode/system_task-scheduler.py new file mode 100755 index 000000000..129be5d3c --- /dev/null +++ b/src/conf_mode/system_task-scheduler.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2017 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 . +# +# + +import os +import re +import sys + +from vyos.config import Config +from vyos import ConfigError + +from vyos import airbag +airbag.enable() + +crontab_file = "/etc/cron.d/vyos-crontab" + + +def format_task(minute="*", hour="*", day="*", dayofweek="*", month="*", user="root", rawspec=None, command=""): + fmt_full = "{minute} {hour} {day} {month} {dayofweek} {user} {command}\n" + fmt_raw = "{spec} {user} {command}\n" + + if rawspec is None: + s = fmt_full.format(minute=minute, hour=hour, day=day, + dayofweek=dayofweek, month=month, command=command, user=user) + else: + s = fmt_raw.format(spec=rawspec, user=user, command=command) + + return s + +def split_interval(s): + result = re.search(r"(\d+)([mdh]?)", s) + value = int(result.group(1)) + suffix = result.group(2) + return( (value, suffix) ) + +def make_command(executable, arguments): + if arguments: + return("sg vyattacfg \"{0} {1}\"".format(executable, arguments)) + else: + return("sg vyattacfg \"{0}\"".format(executable)) + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + conf.set_level("system task-scheduler task") + task_names = conf.list_nodes("") + tasks = [] + + for name in task_names: + interval = conf.return_value("{0} interval".format(name)) + spec = conf.return_value("{0} crontab-spec".format(name)) + executable = conf.return_value("{0} executable path".format(name)) + args = conf.return_value("{0} executable arguments".format(name)) + task = { + "name": name, + "interval": interval, + "spec": spec, + "executable": executable, + "args": args + } + tasks.append(task) + + return tasks + +def verify(tasks): + for task in tasks: + if not task["interval"] and not task["spec"]: + raise ConfigError("Invalid task {0}: must define either interval or crontab-spec".format(task["name"])) + + if task["interval"]: + if task["spec"]: + raise ConfigError("Invalid task {0}: cannot use interval and crontab-spec at the same time".format(task["name"])) + + if not re.match(r"^\d+[mdh]?$", task["interval"]): + raise(ConfigError("Invalid interval {0} in task {1}: interval should be a number optionally followed by m, h, or d".format(task["name"], task["interval"]))) + else: + # Check if values are within allowed range + value, suffix = split_interval(task["interval"]) + + if not suffix or suffix == "m": + if value > 60: + raise ConfigError("Invalid task {0}: interval in minutes must not exceed 60".format(task["name"])) + elif suffix == "h": + if value > 24: + raise ConfigError("Invalid task {0}: interval in hours must not exceed 24".format(task["name"])) + elif suffix == "d": + if value > 31: + raise ConfigError("Invalid task {0}: interval in days must not exceed 31".format(task["name"])) + + if not task["executable"]: + raise ConfigError("Invalid task {0}: executable is not defined".format(task["name"])) + else: + # Check if executable exists and is executable + if not (os.path.isfile(task["executable"]) and os.access(task["executable"], os.X_OK)): + raise ConfigError("Invalid task {0}: file {1} does not exist or is not executable".format(task["name"], task["executable"])) + +def generate(tasks): + crontab_header = "### Generated by vyos-update-crontab.py ###\n" + if len(tasks) == 0: + if os.path.exists(crontab_file): + os.remove(crontab_file) + else: + pass + else: + crontab_lines = [] + for task in tasks: + command = make_command(task["executable"], task["args"]) + if task["spec"]: + line = format_task(command=command, rawspec=task["spec"]) + else: + value, suffix = split_interval(task["interval"]) + if not suffix or suffix == "m": + line = format_task(command=command, minute="*/{0}".format(value)) + elif suffix == "h": + line = format_task(command=command, minute="0", hour="*/{0}".format(value)) + elif suffix == "d": + line = format_task(command=command, minute="0", hour="0", day="*/{0}".format(value)) + crontab_lines.append(line) + + with open(crontab_file, 'w') as f: + f.write(crontab_header) + f.writelines(crontab_lines) + +def apply(config): + # No daemon restarts etc. needed for cron + pass + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/system_timezone.py b/src/conf_mode/system_timezone.py new file mode 100755 index 000000000..cd3d4b229 --- /dev/null +++ b/src/conf_mode/system_timezone.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 . + +import sys +import os + +from copy import deepcopy +from vyos.config import Config +from vyos import ConfigError +from vyos.utils.process import call + +from vyos import airbag +airbag.enable() + +default_config_data = { + 'name': 'UTC' +} + +def get_config(config=None): + tz = deepcopy(default_config_data) + if config: + conf = config + else: + conf = Config() + if conf.exists('system time-zone'): + tz['name'] = conf.return_value('system time-zone') + + return tz + +def verify(tz): + pass + +def generate(tz): + pass + +def apply(tz): + call('/usr/bin/timedatectl set-timezone {}'.format(tz['name'])) + call('systemctl restart rsyslog') + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/conf_mode/system_update-check.py b/src/conf_mode/system_update-check.py new file mode 100755 index 000000000..8d641a97d --- /dev/null +++ b/src/conf_mode/system_update-check.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 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 . + +import os +import json +import jmespath + +from pathlib import Path +from sys import exit + +from vyos.config import Config +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + + +base = ['system', 'update-check'] +service_name = 'vyos-system-update' +service_conf = Path(f'/run/{service_name}.conf') +motd_file = Path('/run/motd.d/10-vyos-update') + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + if not conf.exists(base): + return None + + config = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return config + + +def verify(config): + # bail out early - looks like removal from running config + if config is None: + return + + if 'url' not in config: + raise ConfigError('URL is required!') + + +def generate(config): + # bail out early - looks like removal from running config + if config is None: + # Remove old config and return + service_conf.unlink(missing_ok=True) + # MOTD used in /run/motd.d/10-update + motd_file.unlink(missing_ok=True) + return None + + # Write configuration file + conf_json = json.dumps(config, indent=4) + service_conf.write_text(conf_json) + + return None + + +def apply(config): + if config: + if 'auto_check' in config: + call(f'systemctl restart {service_name}.service') + else: + call(f'systemctl stop {service_name}.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/system_update_check.py b/src/conf_mode/system_update_check.py deleted file mode 100755 index 8d641a97d..000000000 --- a/src/conf_mode/system_update_check.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2022 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 . - -import os -import json -import jmespath - -from pathlib import Path -from sys import exit - -from vyos.config import Config -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - - -base = ['system', 'update-check'] -service_name = 'vyos-system-update' -service_conf = Path(f'/run/{service_name}.conf') -motd_file = Path('/run/motd.d/10-vyos-update') - - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - if not conf.exists(base): - return None - - config = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - return config - - -def verify(config): - # bail out early - looks like removal from running config - if config is None: - return - - if 'url' not in config: - raise ConfigError('URL is required!') - - -def generate(config): - # bail out early - looks like removal from running config - if config is None: - # Remove old config and return - service_conf.unlink(missing_ok=True) - # MOTD used in /run/motd.d/10-update - motd_file.unlink(missing_ok=True) - return None - - # Write configuration file - conf_json = json.dumps(config, indent=4) - service_conf.write_text(conf_json) - - return None - - -def apply(config): - if config: - if 'auto_check' in config: - call(f'systemctl restart {service_name}.service') - else: - call(f'systemctl stop {service_name}.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/task_scheduler.py b/src/conf_mode/task_scheduler.py deleted file mode 100755 index 129be5d3c..000000000 --- a/src/conf_mode/task_scheduler.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2017 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 . -# -# - -import os -import re -import sys - -from vyos.config import Config -from vyos import ConfigError - -from vyos import airbag -airbag.enable() - -crontab_file = "/etc/cron.d/vyos-crontab" - - -def format_task(minute="*", hour="*", day="*", dayofweek="*", month="*", user="root", rawspec=None, command=""): - fmt_full = "{minute} {hour} {day} {month} {dayofweek} {user} {command}\n" - fmt_raw = "{spec} {user} {command}\n" - - if rawspec is None: - s = fmt_full.format(minute=minute, hour=hour, day=day, - dayofweek=dayofweek, month=month, command=command, user=user) - else: - s = fmt_raw.format(spec=rawspec, user=user, command=command) - - return s - -def split_interval(s): - result = re.search(r"(\d+)([mdh]?)", s) - value = int(result.group(1)) - suffix = result.group(2) - return( (value, suffix) ) - -def make_command(executable, arguments): - if arguments: - return("sg vyattacfg \"{0} {1}\"".format(executable, arguments)) - else: - return("sg vyattacfg \"{0}\"".format(executable)) - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - conf.set_level("system task-scheduler task") - task_names = conf.list_nodes("") - tasks = [] - - for name in task_names: - interval = conf.return_value("{0} interval".format(name)) - spec = conf.return_value("{0} crontab-spec".format(name)) - executable = conf.return_value("{0} executable path".format(name)) - args = conf.return_value("{0} executable arguments".format(name)) - task = { - "name": name, - "interval": interval, - "spec": spec, - "executable": executable, - "args": args - } - tasks.append(task) - - return tasks - -def verify(tasks): - for task in tasks: - if not task["interval"] and not task["spec"]: - raise ConfigError("Invalid task {0}: must define either interval or crontab-spec".format(task["name"])) - - if task["interval"]: - if task["spec"]: - raise ConfigError("Invalid task {0}: cannot use interval and crontab-spec at the same time".format(task["name"])) - - if not re.match(r"^\d+[mdh]?$", task["interval"]): - raise(ConfigError("Invalid interval {0} in task {1}: interval should be a number optionally followed by m, h, or d".format(task["name"], task["interval"]))) - else: - # Check if values are within allowed range - value, suffix = split_interval(task["interval"]) - - if not suffix or suffix == "m": - if value > 60: - raise ConfigError("Invalid task {0}: interval in minutes must not exceed 60".format(task["name"])) - elif suffix == "h": - if value > 24: - raise ConfigError("Invalid task {0}: interval in hours must not exceed 24".format(task["name"])) - elif suffix == "d": - if value > 31: - raise ConfigError("Invalid task {0}: interval in days must not exceed 31".format(task["name"])) - - if not task["executable"]: - raise ConfigError("Invalid task {0}: executable is not defined".format(task["name"])) - else: - # Check if executable exists and is executable - if not (os.path.isfile(task["executable"]) and os.access(task["executable"], os.X_OK)): - raise ConfigError("Invalid task {0}: file {1} does not exist or is not executable".format(task["name"], task["executable"])) - -def generate(tasks): - crontab_header = "### Generated by vyos-update-crontab.py ###\n" - if len(tasks) == 0: - if os.path.exists(crontab_file): - os.remove(crontab_file) - else: - pass - else: - crontab_lines = [] - for task in tasks: - command = make_command(task["executable"], task["args"]) - if task["spec"]: - line = format_task(command=command, rawspec=task["spec"]) - else: - value, suffix = split_interval(task["interval"]) - if not suffix or suffix == "m": - line = format_task(command=command, minute="*/{0}".format(value)) - elif suffix == "h": - line = format_task(command=command, minute="0", hour="*/{0}".format(value)) - elif suffix == "d": - line = format_task(command=command, minute="0", hour="0", day="*/{0}".format(value)) - crontab_lines.append(line) - - with open(crontab_file, 'w') as f: - f.write(crontab_header) - f.writelines(crontab_lines) - -def apply(config): - # No daemon restarts etc. needed for cron - pass - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) diff --git a/src/conf_mode/tftp_server.py b/src/conf_mode/tftp_server.py deleted file mode 100755 index 3ad346e2e..000000000 --- a/src/conf_mode/tftp_server.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2020 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 . - -import os -import stat -import pwd - -from copy import deepcopy -from glob import glob -from sys import exit - -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.process import call -from vyos.utils.permission import chmod_755 -from vyos.utils.network import is_addr_assigned -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/etc/default/tftpd' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - - base = ['service', 'tftp-server'] - if not conf.exists(base): - return None - - tftpd = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - return tftpd - -def verify(tftpd): - # bail out early - looks like removal from running config - if not tftpd: - return None - - # Configuring allowed clients without a server makes no sense - if 'directory' not in tftpd: - raise ConfigError('TFTP root directory must be configured!') - - if 'listen_address' not in tftpd: - raise ConfigError('TFTP server listen address must be configured!') - - for address, address_config in tftpd['listen_address'].items(): - if not is_addr_assigned(address): - Warning(f'TFTP server listen address "{address}" not ' \ - 'assigned to any interface!') - verify_vrf(address_config) - - return None - -def generate(tftpd): - # cleanup any available configuration file - # files will be recreated on demand - for i in glob(config_file + '*'): - os.unlink(i) - - # bail out early - looks like removal from running config - if tftpd is None: - return None - - idx = 0 - for address, address_config in tftpd['listen_address'].items(): - config = deepcopy(tftpd) - port = tftpd['port'] - if is_ipv4(address): - config['listen_address'] = f'{address}:{port} -4' - else: - config['listen_address'] = f'[{address}]:{port} -6' - - if 'vrf' in address_config: - config['vrf'] = address_config['vrf'] - - file = config_file + str(idx) - render(file, 'tftp-server/default.j2', config) - idx = idx + 1 - - return None - -def apply(tftpd): - # stop all services first - then we will decide - call('systemctl stop tftpd@*.service') - - # bail out early - e.g. service deletion - if tftpd is None: - return None - - tftp_root = tftpd['directory'] - if not os.path.exists(tftp_root): - os.makedirs(tftp_root) - chmod_755(tftp_root) - - # get UNIX uid for user 'tftp' - tftp_uid = pwd.getpwnam('tftp').pw_uid - tftp_gid = pwd.getpwnam('tftp').pw_gid - - # get UNIX uid for tftproot directory - dir_uid = os.stat(tftp_root).st_uid - dir_gid = os.stat(tftp_root).st_gid - - # adjust uid/gid of tftproot directory if files don't belong to user tftp - if (tftp_uid != dir_uid) or (tftp_gid != dir_gid): - os.chown(tftp_root, tftp_uid, tftp_gid) - - idx = 0 - for address in tftpd['listen_address']: - call(f'systemctl restart tftpd@{idx}.service') - idx = idx + 1 - - 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/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers index 222c75f21..5157469f4 100755 --- a/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers +++ b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers @@ -1,5 +1,4 @@ #!/bin/bash -### Autogenerated by interfaces-pppoe.py ### interface=$6 if [ -z "$interface" ]; then diff --git a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers index 0fcedbedc..4affaeb5c 100755 --- a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers +++ b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers @@ -1,5 +1,4 @@ #!/bin/bash -### Autogenerated by interfaces-pppoe.py ### interface=$6 if [ -z "$interface" ]; then diff --git a/src/init/vyos-router b/src/init/vyos-router index 711681a8e..aaecbf2a1 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -372,11 +372,11 @@ 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_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" - ${vyos_conf_scripts_dir}/system-ip.py || log_failure_msg "could not reset system IPv4 options" - ${vyos_conf_scripts_dir}/system-ipv6.py || log_failure_msg "could not reset system IPv6 options" - ${vyos_conf_scripts_dir}/conntrack.py || log_failure_msg "could not reset conntrack subsystem" + ${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" + ${vyos_conf_scripts_dir}/system_ip.py || log_failure_msg "could not reset system IPv4 options" + ${vyos_conf_scripts_dir}/system_ipv6.py || log_failure_msg "could not reset system IPv6 options" + ${vyos_conf_scripts_dir}/system_conntrack.py || log_failure_msg "could not reset conntrack subsystem" ${vyos_conf_scripts_dir}/container.py || log_failure_msg "could not reset container subsystem" clear_or_override_config_files || log_failure_msg "could not reset config files" diff --git a/src/migration-scripts/https/1-to-2 b/src/migration-scripts/https/1-to-2 index b1cf37ea6..1a2cdc1e7 100755 --- a/src/migration-scripts/https/1-to-2 +++ b/src/migration-scripts/https/1-to-2 @@ -15,7 +15,7 @@ # along with this program. If not, see . # * Move 'api virtual-host' list to 'api-restrict virtual-host' so it -# is owned by https.py instead of http-api.py +# is owned by service_https.py import sys diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 89f929be7..10034e499 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -55,7 +55,7 @@ def connect(interface): if is_wwan_connected(interface): print(f'Interface {interface}: already connected!') else: - call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces-wwan.py') + call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces_wwan.py') else: print(f'Unknown interface {interface}, can not connect. Aborting!') diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index 5e19bdbad..6d33e372d 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -41,7 +41,7 @@ logger.addHandler(logs_handler_syslog) logger.setLevel(logging.DEBUG) mdns_running_file = '/run/mdns_vrrp_active' -mdns_update_command = 'sudo /usr/libexec/vyos/conf_mode/service_mdns-repeater.py' +mdns_update_command = 'sudo /usr/libexec/vyos/conf_mode/service_mdns_repeater.py' # class for all operations class KeepalivedFifo: diff --git a/src/tests/test_task_scheduler.py b/src/tests/test_task_scheduler.py index f15fcde88..130f825e6 100644 --- a/src/tests/test_task_scheduler.py +++ b/src/tests/test_task_scheduler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -17,16 +17,16 @@ import os import tempfile import unittest +import importlib from vyos import ConfigError try: - from src.conf_mode import task_scheduler + task_scheduler = importlib.import_module("src.conf_mode.system_task-scheduler") except ModuleNotFoundError: # for unittest.main() import sys sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) - from src.conf_mode import task_scheduler - + task_scheduler = importlib.import_module("src.conf_mode.system_task-scheduler") class TestUpdateCrontab(unittest.TestCase): -- cgit v1.2.3