diff options
Diffstat (limited to 'src/conf_mode')
45 files changed, 1946 insertions, 833 deletions
diff --git a/src/conf_mode/conntrack.py b/src/conf_mode/conntrack.py new file mode 100755 index 000000000..4e6e39c0f --- /dev/null +++ b/src/conf_mode/conntrack.py @@ -0,0 +1,140 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.util import cmd +from vyos.util import run +from vyos.util import process_named_running +from vyos.util import dict_search +from vyos.template import render +from vyos.xml import defaults +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' + +# 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'], + }, + 'h323' : { + 'ko' : ['nf_nat_h323', 'nf_conntrack_h323'], + }, + 'nfs' : { + 'iptables' : ['VYATTA_CT_HELPER --table raw --proto tcp --dport 111 --jump CT --helper rpc', + 'VYATTA_CT_HELPER --table raw --proto udp --dport 111 --jump CT --helper rpc'], + }, + 'pptp' : { + 'ko' : ['nf_nat_pptp', 'nf_conntrack_pptp'], + }, + 'sip' : { + 'ko' : ['nf_nat_sip', 'nf_conntrack_sip'], + }, + 'sqlnet' : { + 'iptables' : ['VYATTA_CT_HELPER --table raw --proto tcp --dport 1521 --jump CT --helper tns', + 'VYATTA_CT_HELPER --table raw --proto tcp --dport 1525 --jump CT --helper tns', + 'VYATTA_CT_HELPER --table raw --proto tcp --dport 1536 --jump CT --helper tns'], + }, + 'tftp' : { + 'ko' : ['nf_nat_tftp', 'nf_conntrack_tftp'], + }, +} + +def resync_conntrackd(): + tmp = run('/usr/libexec/vyos/conf_mode/conntrack_sync.py') + if tmp > 0: + print('ERROR: error restarting conntrackd!') + +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) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + conntrack = dict_merge(default_values, conntrack) + + return conntrack + +def verify(conntrack): + return None + +def generate(conntrack): + render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.tmpl', conntrack) + render(sysctl_file, 'conntrack/sysctl.conf.tmpl', 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. + for module, module_config in module_map.items(): + if dict_search(f'modules.{module}.disable', conntrack) != None: + if 'ko' in module_config: + for mod in module_config['ko']: + # Only remove the module if it's loaded + if os.path.exists(f'/sys/module/{mod}'): + cmd(f'rmmod {mod}') + if 'iptables' in module_config: + for rule in module_config['iptables']: + print(f'iptables --delete {rule}') + cmd(f'iptables --delete {rule}') + else: + if 'ko' in module_config: + for mod in module_config['ko']: + cmd(f'modprobe {mod}') + if 'iptables' in module_config: + for rule in module_config['iptables']: + # Only install iptables rule if it does not exist + tmp = run(f'iptables --check {rule}') + if tmp > 0: + cmd(f'iptables --insert {rule}') + + + if process_named_running('conntrackd'): + # Reload conntrack-sync daemon to fetch new sysctl values + resync_conntrackd() + + # 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 index 7f22fa2dd..f82a077e6 100755 --- a/src/conf_mode/conntrack_sync.py +++ b/src/conf_mode/conntrack_sync.py @@ -71,15 +71,26 @@ def verify(conntrack): if 'interface' not in conntrack: raise ConfigError('Interface not defined!') - for interface in conntrack['interface']: + 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('Cannot configure all with other protocol') + raise ConfigError('Can not configure expect-sync "all" with other protocols!') if 'listen_address' in conntrack: address = conntrack['listen_address'] diff --git a/src/conf_mode/containers.py b/src/conf_mode/containers.py index 5efdb6a2f..21b47f42a 100755 --- a/src/conf_mode/containers.py +++ b/src/conf_mode/containers.py @@ -75,7 +75,7 @@ def get_config(config=None): base = ['container'] container = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True) + get_first_key=True, no_tag_node_value_mangle=True) # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_values = defaults(base) diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py index 0ed09e130..cdee72e09 100755 --- a/src/conf_mode/dhcp_server.py +++ b/src/conf_mode/dhcp_server.py @@ -18,6 +18,8 @@ import os from ipaddress import ip_address from ipaddress import ip_network +from netaddr import IPAddress +from netaddr import IPRange from sys import exit from vyos.config import Config @@ -163,8 +165,7 @@ def verify(dhcp): # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: - range_start = [] - range_stop = [] + 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!') @@ -179,18 +180,16 @@ def verify(dhcp): raise ConfigError(f'DHCP range "{range}" stop address must be greater or equal\n' \ 'to the ranges start address!') - # Range start address must be unique - if range_config['start'] in range_start: - raise ConfigError('Conflicting DHCP lease range: Pool start\n' \ - 'address "{start}" defined multipe times!'.format(range_config)) + 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!') - # Range stop address must be unique - if range_config['stop'] in range_start: - raise ConfigError('Conflicting DHCP lease range: Pool stop\n' \ - 'address "{stop}" defined multipe times!'.format(range_config)) - - range_start.append(range_config['start']) - range_stop.append(range_config['stop']) + tmp = IPRange(range_config['start'], range_config['stop']) + networks.append(tmp) if 'failover' in subnet_config: for key in ['local_address', 'peer_address', 'name', 'status']: diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py new file mode 100755 index 000000000..8e6ce5b14 --- /dev/null +++ b/src/conf_mode/firewall.py @@ -0,0 +1,73 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +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.util import call +from vyos import ConfigError +from vyos import airbag +from pprint import pprint +airbag.enable() + + +def get_config(config=None): + + if config: + conf = config + else: + conf = Config() + base = ['nfirewall'] + firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + pprint(firewall) + return firewall + +def verify(firewall): + # bail out early - looks like removal from running config + if not firewall: + return None + + return None + +def generate(firewall): + if not firewall: + return None + + return None + +def apply(firewall): + if not firewall: + return None + + 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/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 0727b47a8..9cae29481 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -43,7 +43,7 @@ uacctd_conf_path = '/etc/pmacct/uacctd.conf' iptables_nflog_table = 'raw' iptables_nflog_chain = 'VYATTA_CT_PREROUTING_HOOK' egress_iptables_nflog_table = 'mangle' -egress_iptables_nflog_chain = 'POSTROUTING' +egress_iptables_nflog_chain = 'FORWARD' # helper functions # check if node exists and return True if this is true diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index a6e2d9c8c..be4380462 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# 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 @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import os import sys from copy import deepcopy @@ -23,13 +24,17 @@ import vyos.certbot_util from vyos.config import Config from vyos import ConfigError -from vyos.util import call +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render +from vyos.util import call from vyos import airbag airbag.enable() config_file = '/etc/nginx/sites-available/default' +cert_dir = '/etc/ssl/certs' +key_dir = '/etc/ssl/private' certbot_dir = vyos.defaults.directories['certbot'] # https config needs to coordinate several subsystems: api, certbot, @@ -56,12 +61,58 @@ def get_config(config=None): if not conf.exists('service https'): return None + https = conf.get_config_dict('service https', get_first_key=True) + + if https: + https['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + return https + +def verify(https): + 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, 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 <id> server-name' " + "matching the 'certbot domain-name' is required.") + return None + +def generate(https): + if https is None: + return None + server_block_list = [] - https_dict = conf.get_config_dict('service https', get_first_key=True) # organize by vhosts - vhost_dict = https_dict.get('virtual-host', {}) + vhost_dict = https.get('virtual-host', {}) if not vhost_dict: # no specified virtual hosts (server blocks); use default @@ -79,18 +130,30 @@ def get_config(config=None): # get certificate data - cert_dict = https_dict.get('certificates', {}) + 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') + + with open(cert_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + with open(key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) - # self-signed certificate + vyos_cert_data = { + "crt": cert_path, + "key": key_path + } - vyos_cert_data = {} - if 'system-generated-certificate' in list(cert_dict): - vyos_cert_data = vyos.defaults.vyos_cert_data - if vyos_cert_data: for block in server_block_list: block['vyos_cert'] = vyos_cert_data - # letsencrypt certificate using certbot + # letsencrypt certificate using certbot certbot = False cert_domains = cert_dict.get('certbot', {}).get('domain-name', []) @@ -110,15 +173,15 @@ def get_config(config=None): api_set = False api_data = {} - if 'api' in list(https_dict): + if 'api' in list(https): api_set = True api_data = vyos.defaults.api_data - api_settings = https_dict.get('api', {}) + api_settings = https.get('api', {}) if api_settings: port = api_settings.get('port', '') if port: api_data['port'] = port - vhosts = https_dict.get('api-restrict', {}).get('virtual-host', []) + vhosts = https.get('api-restrict', {}).get('virtual-host', []) if vhosts: api_data['vhost'] = vhosts[:] @@ -132,34 +195,16 @@ def get_config(config=None): if block['id'] in vhost_list: block['api'] = api_data - # return dict for use in template - - https = {'server_block_list' : server_block_list, - 'api_set': api_set, - 'certbot': certbot} - - return https - -def verify(https): - if https is None: - return None - - if https['certbot']: - for sb in https['server_block_list']: - if sb['certbot']: - return None - raise ConfigError("At least one 'virtual-host <id> server-name' " - "matching the 'certbot domain-name' is required.") - return None - -def generate(https): - if https is None: - return None - if 'server_block_list' not in https or not https['server_block_list']: https['server_block_list'] = [default_server_block] - render(config_file, 'https/nginx.default.tmpl', https) + data = { + 'server_block_list': server_block_list, + 'api_set': api_set, + 'certbot': certbot + } + + render(config_file, 'https/nginx.default.tmpl', data) return None diff --git a/src/conf_mode/interfaces-dummy.py b/src/conf_mode/interfaces-dummy.py index 44fc9cb9e..55c783f38 100755 --- a/src/conf_mode/interfaces-dummy.py +++ b/src/conf_mode/interfaces-dummy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# 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 @@ -14,8 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os - from sys import exit from vyos.config import Config @@ -42,7 +40,7 @@ def get_config(config=None): return dummy def verify(dummy): - if 'deleted' in dummy.keys(): + if 'deleted' in dummy: verify_bridge_delete(dummy) return None @@ -58,7 +56,7 @@ def apply(dummy): d = DummyIf(dummy['ifname']) # Remove dummy interface - if 'deleted' in dummy.keys(): + if 'deleted' in dummy: d.remove() else: d.update(dummy) diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index 378f400b8..78c24952b 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -32,6 +32,8 @@ from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf from vyos.ethtool import Ethtool from vyos.ifconfig import EthernetIf +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call from vyos.util import dict_search @@ -40,6 +42,7 @@ 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 get_config(config=None): @@ -52,8 +55,15 @@ def get_config(config=None): else: conf = Config() base = ['interfaces', 'ethernet'] + + tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + ethernet = get_interface_dict(conf, base) + if 'deleted' not in ethernet: + ethernet['pki'] = tmp_pki + return ethernet def verify(ethernet): @@ -126,6 +136,27 @@ def generate(ethernet): if 'eapol' in ethernet: render(wpa_suppl_conf.format(**ethernet), 'ethernet/wpa_supplicant.conf.tmpl', ethernet) + + ifname = ethernet['ifname'] + 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] + + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + with open(cert_key_path, 'w') as f: + f.write(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_cert_name = ethernet['eapol']['ca_certificate'] + pki_ca_cert = ethernet['pki']['ca'][cert_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) else: # delete configuration on interface removal if os.path.isfile(wpa_suppl_conf.format(**ethernet)): diff --git a/src/conf_mode/interfaces-loopback.py b/src/conf_mode/interfaces-loopback.py index 30a27abb4..193334443 100755 --- a/src/conf_mode/interfaces-loopback.py +++ b/src/conf_mode/interfaces-loopback.py @@ -45,8 +45,8 @@ def generate(loopback): return None def apply(loopback): - l = LoopbackIf(loopback['ifname']) - if 'deleted' in loopback.keys(): + l = LoopbackIf(**loopback) + if 'deleted' in loopback: l.remove() else: l.update(loopback) diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 4afb85526..74e29ed82 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -17,6 +17,7 @@ import os import re +from cryptography.hazmat.primitives.asymmetric import ec from glob import glob from sys import exit from ipaddress import IPv4Address @@ -31,8 +32,14 @@ from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configverify import verify_vrf from vyos.configverify import verify_bridge_delete -from vyos.configverify import verify_diffie_hellman_length from vyos.ifconfig import VTunIf +from vyos.pki import load_dh_parameters +from vyos.pki import load_private_key +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 @@ -40,6 +47,7 @@ from vyos.util import call from vyos.util import chown from vyos.util import chmod_600 from vyos.util import dict_search +from vyos.util import dict_search_args from vyos.validate import is_addr_assigned from vyos import ConfigError @@ -49,23 +57,9 @@ airbag.enable() user = 'openvpn' group = 'openvpn' +cfg_dir = '/run/openvpn' cfg_file = '/run/openvpn/{ifname}.conf' -def checkCertHeader(header, filename): - """ - Verify if filename contains specified header. - Returns True if match is found, False if no match or file is not found - """ - if not os.path.isfile(filename): - return False - - with open(filename, 'r') as f: - for line in f: - if re.match(header, line): - return True - - return False - def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -76,14 +70,105 @@ def get_config(config=None): else: conf = Config() base = ['interfaces', 'openvpn'] + + tmp_pki = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + openvpn = get_interface_dict(conf, base) + if 'deleted' not in openvpn: + openvpn['pki'] = tmp_pki + openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn) openvpn['daemon_user'] = user openvpn['daemon_group'] = group 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 tls: + if 'ca_certificate' not in tls: + raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface}') + + if tls['ca_certificate'] not in pki['ca']: + raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}') + + if not (mode == 'client' and 'auth_key' 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'): + raise ConfigError(f'Cannot use encrypted private key on openvpn interface {interface}') + + if mode == 'server' and 'dh_params' not in tls and not is_ec_private_key(pki, tls['certificate']): + raise ConfigError('Must specify "tls dh-params" when not using EC keys in server mode') + + if 'dh_params' in tls: + if 'dh' not in pki: + raise ConfigError('There are no DH parameters in PKI configuration') + + if tls['dh_params'] not in pki['dh']: + raise ConfigError(f'Invalid dh-params on openvpn interface {interface}') + + 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: verify_bridge_delete(openvpn) @@ -92,12 +177,6 @@ def verify(openvpn): if 'mode' not in openvpn: raise ConfigError('Must specify OpenVPN operation mode!') - # Check if we have disabled ncp and at the same time specified ncp-ciphers - if 'encryption' in openvpn: - if {'disable_ncp', 'ncp_ciphers'} <= set(openvpn.get('encryption')): - raise ConfigError('Can not specify both "encryption disable-ncp" '\ - 'and "encryption ncp-ciphers"') - # # OpenVPN client mode - VERIFY # @@ -114,8 +193,8 @@ def verify(openvpn): if openvpn['protocol'] == 'tcp-passive': raise ConfigError('Protocol "tcp-passive" is not valid in client mode') - if dict_search('tls.dh_file', openvpn): - raise ConfigError('Cannot specify "tls dh-file" 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 @@ -200,11 +279,6 @@ def verify(openvpn): if 'remote_host' in openvpn: raise ConfigError('Cannot specify "remote-host" in server mode') - if 'tls' in openvpn: - if 'dh_file' not in openvpn['tls']: - if 'key_file' in openvpn['tls'] and not checkCertHeader('-----BEGIN EC PRIVATE KEY-----', openvpn['tls']['key_file']): - raise ConfigError('Must specify "tls dh-file" when not using EC keys in server mode') - tmp = dict_search('server.subnet', openvpn) if tmp: v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)]) @@ -312,97 +386,40 @@ def verify(openvpn): if 'remote_host' not in openvpn: raise ConfigError('Must specify "remote-host" with "tcp-active"') - # shared secret and TLS - if not ('shared_secret_key_file' in openvpn or 'tls' in openvpn): - raise ConfigError('Must specify one of "shared-secret-key-file" and "tls"') - - if {'shared_secret_key_file', 'tls'} <= set(openvpn): - raise ConfigError('Can only specify one of "shared-secret-key-file" and "tls"') - - if openvpn['mode'] in ['client', 'server']: - if 'tls' not in openvpn: - raise ConfigError('Must specify "tls" for server and client mode') - # # TLS/encryption # - if 'shared_secret_key_file' in openvpn: + if 'shared_secret_key' in openvpn: if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']: - raise ConfigError('GCM encryption with shared-secret-key-file not supported') - - file = dict_search('shared_secret_key_file', openvpn) - if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): - raise ConfigError(f'Specified shared-secret-key-file "{file}" is not valid') + raise ConfigError('GCM encryption with shared-secret-key not supported') if 'tls' in openvpn: - if 'ca_cert_file' not in openvpn['tls']: - raise ConfigError('Must specify "tls ca-cert-file"') - - if not (openvpn['mode'] == 'client' and 'auth_file' in openvpn['tls']): - if 'cert_file' not in openvpn['tls']: - raise ConfigError('Missing "tls cert-file"') - - if 'key_file' not in openvpn['tls']: - raise ConfigError('Missing "tls key-file"') - - if {'auth_file', 'crypt_file'} <= set(openvpn['tls']): - raise ConfigError('TLS auth and crypt are mutually exclusive') - - file = dict_search('tls.ca_cert_file', openvpn) - if file and not checkCertHeader('-----BEGIN CERTIFICATE-----', file): - raise ConfigError(f'Specified ca-cert-file "{file}" is invalid') - - file = dict_search('tls.auth_file', openvpn) - if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): - raise ConfigError(f'Specified auth-file "{file}" is invalid') - - file = dict_search('tls.cert_file', openvpn) - if file and not checkCertHeader('-----BEGIN CERTIFICATE-----', file): - raise ConfigError(f'Specified cert-file "{file}" is invalid') - - file = dict_search('tls.key_file', openvpn) - if file and not checkCertHeader('-----BEGIN (?:RSA |EC )?PRIVATE KEY-----', file): - raise ConfigError(f'Specified key-file "{file}" is not valid') - - file = dict_search('tls.crypt_file', openvpn) - if file and not checkCertHeader('-----BEGIN OpenVPN Static key V1-----', file): - raise ConfigError(f'Specified TLS crypt-file "{file}" is invalid') - - file = dict_search('tls.crl_file', openvpn) - if file and not checkCertHeader('-----BEGIN X509 CRL-----', file): - raise ConfigError(f'Specified crl-file "{file} not valid') - - file = dict_search('tls.dh_file', openvpn) - if file and not checkCertHeader('-----BEGIN DH PARAMETERS-----', file): - raise ConfigError(f'Specified dh-file "{file}" is not valid') - - if file and not verify_diffie_hellman_length(file, 2048): - raise ConfigError(f'Minimum DH key-size is 2048 bits') + 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_file', openvpn): + 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_file', openvpn): - raise ConfigError('Cannot specify "tls dh-file" 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 not dict_search('tls.dh_file', openvpn): - raise ConfigError('Must specify "tls dh-file" when "tls role" is "passive"') + if not dict_search('tls.dh_params', openvpn): + raise ConfigError('Must specify "tls dh-params" when "tls role" is "passive"') - file = dict_search('tls.key_file', openvpn) - if file and checkCertHeader('-----BEGIN EC PRIVATE KEY-----', file): - if dict_search('tls.dh_file', openvpn): - print('Warning: using dh-file and EC keys simultaneously will ' \ + 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': @@ -410,6 +427,8 @@ def verify(openvpn): print('No encryption will be performed and data is transmitted in ' \ 'plain text over the network!') + verify_pki(openvpn) + # # Auth user/pass # @@ -425,6 +444,110 @@ def verify(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') + + files = [] + + if shared_secret_key: + pki_key = pki['openvpn']['shared_secret'][shared_secret_key] + key_path = os.path.join(cfg_dir, f'{interface}_shared.key') + + with open(key_path, 'w') as f: + f.write(wrap_openvpn_key(pki_key['key'])) + + files.append(key_path) + + if tls: + if 'ca_certificate' in tls: + cert_name = tls['ca_certificate'] + pki_ca = pki['ca'][cert_name] + + if 'certificate' in pki_ca: + cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem') + + with open(cert_path, 'w') as f: + f.write(wrap_certificate(pki_ca['certificate'])) + + files.append(cert_path) + + if 'crl' in pki_ca: + for crl in pki_ca['crl']: + crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem') + + with open(crl_path, 'w') as f: + f.write(wrap_crl(crl)) + + files.append(crl_path) + 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') + + with open(cert_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + files.append(cert_path) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + key_path = os.path.join(cfg_dir, f'{interface}_cert.key') + + with open(key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) + + files.append(key_path) + 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') + + with open(dh_path, 'w') as f: + f.write(wrap_dh_parameters(pki_dh['parameters'])) + + files.append(dh_path) + + 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') + + with open(key_path, 'w') as f: + f.write(wrap_openvpn_key(pki_key['key'])) + + files.append(key_path) + + 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') + + with open(key_path, 'w') as f: + f.write(wrap_openvpn_key(pki_key['key'])) + + files.append(key_path) + + return files + + def generate(openvpn): interface = openvpn['ifname'] directory = os.path.dirname(cfg_file.format(**openvpn)) @@ -444,13 +567,7 @@ def generate(openvpn): chown(ccd_dir, user, group) # Fix file permissons for keys - fix_permissions = [] - - tmp = dict_search('shared_secret_key_file', openvpn) - if tmp: fix_permissions.append(openvpn['shared_secret_key_file']) - - tmp = dict_search('tls.key_file', openvpn) - if tmp: fix_permissions.append(tmp) + fix_permissions = generate_pki_files(openvpn) # Generate User/Password authentication file if 'authentication' in openvpn: @@ -462,8 +579,9 @@ def generate(openvpn): os.remove(openvpn['auth_user_pass_file']) # Generate client specific configuration - if dict_search('server.client', openvpn): - for client, client_config in dict_search('server.client', openvpn).items(): + 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 ... diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 3675db73b..6c4c6c95b 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -22,6 +22,7 @@ from netifaces import interfaces from vyos.config import Config from vyos.configdict import get_interface_dict +from vyos.configverify import verify_authentication from vyos.configverify import verify_source_interface from vyos.configverify import verify_vrf from vyos.configverify import verify_mtu_ipv6 @@ -51,6 +52,7 @@ def verify(pppoe): return None verify_source_interface(pppoe) + verify_authentication(pppoe) verify_vrf(pppoe) verify_mtu_ipv6(pppoe) diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 34a054837..945a2ea9c 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -24,6 +24,7 @@ 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.ifconfig import MACVLANIf from vyos import ConfigError @@ -45,6 +46,9 @@ def get_config(config=None): mode = leaf_node_changed(conf, ['mode']) if mode: peth.update({'mode_old' : mode}) + if 'source_interface' in peth: + peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'], + peth['source_interface']) return peth def verify(peth): @@ -55,9 +59,10 @@ def verify(peth): verify_source_interface(peth) verify_vrf(peth) verify_address(peth) - + verify_mtu_parent(peth, peth['parent']) # use common function to verify VLAN configuration verify_vlan_config(peth) + return None def generate(peth): diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index 4e6c8a9ab..294da8ef9 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -109,6 +109,14 @@ def verify(tunnel): 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 diff --git a/src/conf_mode/interfaces-vti.py b/src/conf_mode/interfaces-vti.py new file mode 100755 index 000000000..57950ffea --- /dev/null +++ b/src/conf_mode/interfaces-vti.py @@ -0,0 +1,66 @@ +#!/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 <http://www.gnu.org/licenses/>. + +from netifaces import interfaces +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.ifconfig import VTIIf +from vyos.util 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): + 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 index 8e6247a30..804f2d14f 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -25,7 +25,9 @@ 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_source_interface -from vyos.ifconfig import VXLANIf, Interface +from vyos.ifconfig import Interface +from vyos.ifconfig import VXLANIf +from vyos.template import is_ipv6 from vyos import ConfigError from vyos import airbag airbag.enable() @@ -65,12 +67,19 @@ def verify(vxlan): raise ConfigError('Must configure VNI for VXLAN') if 'source_interface' in vxlan: - # VXLAN adds a 50 byte overhead - we need to check the underlaying MTU - # if our configured MTU is at least 50 bytes less + # 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 + lower_mtu = Interface(vxlan['source_interface']).get_mtu() - if lower_mtu < (int(vxlan['mtu']) + 50): - raise ConfigError('VXLAN has a 50 byte overhead, underlaying device ' \ - f'MTU is to small ({lower_mtu} bytes)') + 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!)') verify_mtu_ipv6(vxlan) verify_address(vxlan) diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 024ab8f59..4c566a5ad 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -46,17 +46,14 @@ def get_config(config=None): base = ['interfaces', 'wireguard'] wireguard = get_interface_dict(conf, base) - # Mangle private key - it has a default so its always valid - wireguard['private_key'] = '/config/auth/wireguard/{private_key}/private.key'.format(**wireguard) - # Determine which Wireguard peer has been removed. # Peers can only be removed with their public key! dict = {} tmp = node_changed(conf, ['peer'], key_mangling=('-', '_')) for peer in (tmp or []): - pubkey = leaf_node_changed(conf, ['peer', peer, 'pubkey']) - if pubkey: - dict = dict_merge({'peer_remove' : {peer : {'pubkey' : pubkey[0]}}}, dict) + public_key = leaf_node_changed(conf, ['peer', peer, 'public_key']) + if public_key: + dict = dict_merge({'peer_remove' : {peer : {'public_key' : public_key[0]}}}, dict) wireguard.update(dict) return wireguard @@ -70,9 +67,8 @@ def verify(wireguard): verify_address(wireguard) verify_vrf(wireguard) - if not os.path.exists(wireguard['private_key']): - raise ConfigError('Wireguard private-key not found! Execute: ' \ - '"run generate wireguard [default-keypair|named-keypairs]"') + 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!') @@ -84,7 +80,7 @@ def verify(wireguard): if 'allowed_ips' not in peer: raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') - if 'pubkey' not in peer: + 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): diff --git a/src/conf_mode/interfaces-wirelessmodem.py b/src/conf_mode/interfaces-wirelessmodem.py deleted file mode 100755 index 976953b31..000000000 --- a/src/conf_mode/interfaces-wirelessmodem.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 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 <http://www.gnu.org/licenses/>. - -import os - -from sys import exit - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.configverify import verify_vrf -from vyos.template import render -from vyos.util import call -from vyos.util import check_kmod -from vyos.util import find_device_file -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -k_mod = ['option', 'usb_wwan', 'usbserial'] - -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', 'wirelessmodem'] - wwan = get_interface_dict(conf, base) - - return wwan - -def verify(wwan): - if 'deleted' in wwan: - return None - - if not 'apn' in wwan: - raise ConfigError('No APN configured for "{ifname}"'.format(**wwan)) - - if not 'device' in wwan: - raise ConfigError('Physical "device" must be configured') - - # we can not use isfile() here as Linux device files are no regular files - # thus the check will return False - dev_path = find_device_file(wwan['device']) - if dev_path is None or not os.path.exists(dev_path): - raise ConfigError('Device "{device}" does not exist'.format(**wwan)) - - verify_vrf(wwan) - - return None - -def generate(wwan): - # set up configuration file path variables where our templates will be - # rendered into - ifname = wwan['ifname'] - config_wwan = f'/etc/ppp/peers/{ifname}' - config_wwan_chat = f'/etc/ppp/peers/chat.{ifname}' - script_wwan_pre_up = f'/etc/ppp/ip-pre-up.d/1010-vyos-wwan-{ifname}' - script_wwan_ip_up = f'/etc/ppp/ip-up.d/1010-vyos-wwan-{ifname}' - script_wwan_ip_down = f'/etc/ppp/ip-down.d/1010-vyos-wwan-{ifname}' - - config_files = [config_wwan, config_wwan_chat, script_wwan_pre_up, - script_wwan_ip_up, script_wwan_ip_down] - - # Always hang-up WWAN connection prior generating new configuration file - call(f'systemctl stop ppp@{ifname}.service') - - if 'deleted' in wwan: - # Delete PPP configuration files - for file in config_files: - if os.path.exists(file): - os.unlink(file) - - else: - wwan['device'] = find_device_file(wwan['device']) - - # Create PPP configuration files - render(config_wwan, 'wwan/peer.tmpl', wwan) - # Create PPP chat script - render(config_wwan_chat, 'wwan/chat.tmpl', wwan) - - # generated script file must be executable - - # Create script for ip-pre-up.d - render(script_wwan_pre_up, 'wwan/ip-pre-up.script.tmpl', - wwan, permission=0o755) - # Create script for ip-up.d - render(script_wwan_ip_up, 'wwan/ip-up.script.tmpl', - wwan, permission=0o755) - # Create script for ip-down.d - render(script_wwan_ip_down, 'wwan/ip-down.script.tmpl', - wwan, permission=0o755) - - return None - -def apply(wwan): - if 'deleted' in wwan: - # bail out early - return None - - if not 'disable' in wwan: - # "dial" WWAN connection - call('systemctl start ppp@{ifname}.service'.format(**wwan)) - - 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-wwan.py b/src/conf_mode/interfaces-wwan.py new file mode 100755 index 000000000..31c599145 --- /dev/null +++ b/src/conf_mode/interfaces-wwan.py @@ -0,0 +1,105 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configverify import verify_authentication +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_vrf +from vyos.ifconfig import WWANIf +from vyos.util import cmd +from vyos.util import dict_search +from vyos.template import render +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', 'wwan'] + wwan = get_interface_dict(conf, base) + + 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) + + return None + +def generate(wwan): + return None + +def apply(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() + return None + + 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={user},password={password}'.format(**wwan['authentication']) + + command = f'{base_cmd} --simple-connect="{options}"' + cmd(command) + 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/ipsec-settings.py b/src/conf_mode/ipsec-settings.py deleted file mode 100755 index a65e8b567..000000000 --- a/src/conf_mode/ipsec-settings.py +++ /dev/null @@ -1,230 +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 <http://www.gnu.org/licenses/>. - -import re -import os - -from time import sleep -from sys import exit - -from vyos.config import Config -from vyos import ConfigError -from vyos.util import call -from vyos.template import render - -from vyos import airbag -airbag.enable() - -ra_conn_name = "remote-access" -charon_conf_file = "/etc/strongswan.d/charon.conf" -ipsec_secrets_file = "/etc/ipsec.secrets" -ipsec_ra_conn_dir = "/etc/ipsec.d/tunnels/" -ipsec_ra_conn_file = ipsec_ra_conn_dir + ra_conn_name -ipsec_conf_file = "/etc/ipsec.conf" -ca_cert_path = "/etc/ipsec.d/cacerts" -server_cert_path = "/etc/ipsec.d/certs" -server_key_path = "/etc/ipsec.d/private" -delim_ipsec_l2tp_begin = "### VyOS L2TP VPN Begin ###" -delim_ipsec_l2tp_end = "### VyOS L2TP VPN End ###" -charon_pidfile = "/var/run/charon.pid" - -def get_config(config=None): - if config: - config = config - else: - config = Config() - data = {"install_routes": "yes"} - - if config.exists("vpn ipsec options disable-route-autoinstall"): - data["install_routes"] = "no" - - if config.exists("vpn ipsec ipsec-interfaces interface"): - data["ipsec_interfaces"] = config.return_values("vpn ipsec ipsec-interfaces interface") - - # Init config variables - data["delim_ipsec_l2tp_begin"] = delim_ipsec_l2tp_begin - data["delim_ipsec_l2tp_end"] = delim_ipsec_l2tp_end - data["ipsec_ra_conn_file"] = ipsec_ra_conn_file - data["ra_conn_name"] = ra_conn_name - # Get l2tp ipsec settings - data["ipsec_l2tp"] = False - conf_ipsec_command = "vpn l2tp remote-access ipsec-settings " #last space is useful - if config.exists(conf_ipsec_command): - data["ipsec_l2tp"] = True - - # Authentication params - if config.exists(conf_ipsec_command + "authentication mode"): - data["ipsec_l2tp_auth_mode"] = config.return_value(conf_ipsec_command + "authentication mode") - if config.exists(conf_ipsec_command + "authentication pre-shared-secret"): - data["ipsec_l2tp_secret"] = config.return_value(conf_ipsec_command + "authentication pre-shared-secret") - - # mode x509 - if config.exists(conf_ipsec_command + "authentication x509 ca-cert-file"): - data["ipsec_l2tp_x509_ca_cert_file"] = config.return_value(conf_ipsec_command + "authentication x509 ca-cert-file") - if config.exists(conf_ipsec_command + "authentication x509 crl-file"): - data["ipsec_l2tp_x509_crl_file"] = config.return_value(conf_ipsec_command + "authentication x509 crl-file") - if config.exists(conf_ipsec_command + "authentication x509 server-cert-file"): - data["ipsec_l2tp_x509_server_cert_file"] = config.return_value(conf_ipsec_command + "authentication x509 server-cert-file") - data["server_cert_file_copied"] = server_cert_path+"/"+re.search('\w+(?:\.\w+)*$', config.return_value(conf_ipsec_command + "authentication x509 server-cert-file")).group(0) - if config.exists(conf_ipsec_command + "authentication x509 server-key-file"): - data["ipsec_l2tp_x509_server_key_file"] = config.return_value(conf_ipsec_command + "authentication x509 server-key-file") - data["server_key_file_copied"] = server_key_path+"/"+re.search('\w+(?:\.\w+)*$', config.return_value(conf_ipsec_command + "authentication x509 server-key-file")).group(0) - if config.exists(conf_ipsec_command + "authentication x509 server-key-password"): - data["ipsec_l2tp_x509_server_key_password"] = config.return_value(conf_ipsec_command + "authentication x509 server-key-password") - - # Common l2tp ipsec params - if config.exists(conf_ipsec_command + "ike-lifetime"): - data["ipsec_l2tp_ike_lifetime"] = config.return_value(conf_ipsec_command + "ike-lifetime") - else: - data["ipsec_l2tp_ike_lifetime"] = "3600" - - if config.exists(conf_ipsec_command + "lifetime"): - data["ipsec_l2tp_lifetime"] = config.return_value(conf_ipsec_command + "lifetime") - else: - data["ipsec_l2tp_lifetime"] = "3600" - - if config.exists("vpn l2tp remote-access outside-address"): - data['outside_addr'] = config.return_value('vpn l2tp remote-access outside-address') - - return data - -def write_ipsec_secrets(c): - if c.get("ipsec_l2tp_auth_mode") == "pre-shared-secret": - secret_txt = "{0}\n{1} %any : PSK \"{2}\"\n{3}\n".format(delim_ipsec_l2tp_begin, c['outside_addr'], c['ipsec_l2tp_secret'], delim_ipsec_l2tp_end) - elif c.get("ipsec_l2tp_auth_mode") == "x509": - secret_txt = "{0}\n: RSA {1}\n{2}\n".format(delim_ipsec_l2tp_begin, c['server_key_file_copied'], delim_ipsec_l2tp_end) - - old_umask = os.umask(0o077) - with open(ipsec_secrets_file, 'a+') as f: - f.write(secret_txt) - os.umask(old_umask) - -def write_ipsec_conf(c): - ipsec_confg_txt = "{0}\ninclude {1}\n{2}\n".format(delim_ipsec_l2tp_begin, ipsec_ra_conn_file, delim_ipsec_l2tp_end) - - old_umask = os.umask(0o077) - with open(ipsec_conf_file, 'a+') as f: - f.write(ipsec_confg_txt) - os.umask(old_umask) - -### Remove config from file by delimiter -def remove_confs(delim_begin, delim_end, conf_file): - call("sed -i '/"+delim_begin+"/,/"+delim_end+"/d' "+conf_file) - - -### Checking certificate storage and notice if certificate not in /config directory -def check_cert_file_store(cert_name, file_path, dts_path): - if not re.search('^\/config\/.+', file_path): - print("Warning: \"" + file_path + "\" lies outside of /config/auth directory. It will not get preserved during image upgrade.") - #Checking file existence - if not os.path.isfile(file_path): - raise ConfigError("L2TP VPN configuration error: Invalid "+cert_name+" \""+file_path+"\"") - else: - ### Cpy file to /etc/ipsec.d/certs/ /etc/ipsec.d/cacerts/ - # todo make check - ret = call('cp -f '+file_path+' '+dts_path) - if ret: - raise ConfigError("L2TP VPN configuration error: Cannot copy "+file_path) - -def verify(data): - # l2tp ipsec check - if data["ipsec_l2tp"]: - # Checking dependecies for "authentication mode pre-shared-secret" - if data.get("ipsec_l2tp_auth_mode") == "pre-shared-secret": - if not data.get("ipsec_l2tp_secret"): - raise ConfigError("pre-shared-secret required") - if not data.get("outside_addr"): - raise ConfigError("outside-address not defined") - - # Checking dependecies for "authentication mode x509" - if data.get("ipsec_l2tp_auth_mode") == "x509": - if not data.get("ipsec_l2tp_x509_server_key_file"): - raise ConfigError("L2TP VPN configuration error: \"server-key-file\" not defined.") - else: - check_cert_file_store("server-key-file", data['ipsec_l2tp_x509_server_key_file'], server_key_path) - - if not data.get("ipsec_l2tp_x509_server_cert_file"): - raise ConfigError("L2TP VPN configuration error: \"server-cert-file\" not defined.") - else: - check_cert_file_store("server-cert-file", data['ipsec_l2tp_x509_server_cert_file'], server_cert_path) - - if not data.get("ipsec_l2tp_x509_ca_cert_file"): - raise ConfigError("L2TP VPN configuration error: \"ca-cert-file\" must be defined for X.509") - else: - check_cert_file_store("ca-cert-file", data['ipsec_l2tp_x509_ca_cert_file'], ca_cert_path) - - if not data.get('ipsec_interfaces'): - raise ConfigError("L2TP VPN configuration error: \"vpn ipsec ipsec-interfaces\" must be specified.") - -def generate(data): - render(charon_conf_file, 'ipsec/charon.tmpl', data) - - if data["ipsec_l2tp"]: - remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_secrets_file) - # old_umask = os.umask(0o077) - # render(ipsec_secrets_file, 'ipsec/ipsec.secrets.tmpl', data) - # os.umask(old_umask) - ## Use this method while IPSec CLI handler won't be overwritten to python - write_ipsec_secrets(data) - - old_umask = os.umask(0o077) - - # Create tunnels directory if does not exist - if not os.path.exists(ipsec_ra_conn_dir): - os.makedirs(ipsec_ra_conn_dir) - - render(ipsec_ra_conn_file, 'ipsec/remote-access.tmpl', data) - os.umask(old_umask) - - remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_conf_file) - # old_umask = os.umask(0o077) - # render(ipsec_conf_file, 'ipsec/ipsec.conf.tmpl', data) - # os.umask(old_umask) - ## Use this method while IPSec CLI handler won't be overwritten to python - write_ipsec_conf(data) - - else: - if os.path.exists(ipsec_ra_conn_file): - remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_ra_conn_file) - remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_secrets_file) - remove_confs(delim_ipsec_l2tp_begin, delim_ipsec_l2tp_end, ipsec_conf_file) - -def restart_ipsec(): - call('ipsec restart >&/dev/null') - # counter for apply swanctl config - counter = 10 - while counter <= 10: - if os.path.exists(charon_pidfile): - call('swanctl -q >&/dev/null') - break - counter -=1 - sleep(1) - if counter == 0: - raise ConfigError('VPN configuration error: IPSec is not running.') - -def apply(data): - # Restart IPSec daemon - restart_ipsec() - -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 index 755c89966..6e169a3d5 100755 --- a/src/conf_mode/le_cert.py +++ b/src/conf_mode/le_cert.py @@ -22,6 +22,7 @@ from vyos.config import Config from vyos import ConfigError from vyos.util import cmd from vyos.util import call +from vyos.util import is_systemd_service_running from vyos import airbag airbag.enable() @@ -87,8 +88,7 @@ def generate(cert): # certbot will attempt to reload nginx, even with 'certonly'; # start nginx if not active - ret = call('systemctl is-active --quiet nginx.service') - if ret: + if not is_systemd_service_running('nginx.service'): call('systemctl start nginx.service') request_certbot(cert) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py new file mode 100755 index 000000000..ef1b57650 --- /dev/null +++ b/src/conf_mode/pki.py @@ -0,0 +1,167 @@ +#!/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 <http://www.gnu.org/licenses/>. + +from sys import exit + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.pki import is_ca_certificate +from vyos.pki import load_certificate +from vyos.pki import load_certificate_request +from vyos.pki import load_public_key +from vyos.pki import load_private_key +from vyos.pki import load_crl +from vyos.pki import load_dh_parameters +from vyos.util import ask_input +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['pki'] + if not conf.exists(base): + return None + + pki = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + default_values = defaults(base) + pki = dict_merge(default_values, pki) + return pki + +def is_valid_certificate(raw_data): + # If it loads correctly we're good, or return False + return load_certificate(raw_data, wrap_tags=True) + +def is_valid_ca_certificate(raw_data): + # Check if this is a valid certificate with CA attributes + cert = load_certificate(raw_data, wrap_tags=True) + if not cert: + return False + return is_ca_certificate(cert) + +def is_valid_public_key(raw_data): + # If it loads correctly we're good, or return False + return load_public_key(raw_data, wrap_tags=True) + +def is_valid_private_key(raw_data, protected=False): + # If it loads correctly we're good, or return False + # With encrypted private keys, we always return true as we cannot ask for password to verify + if protected: + return True + return load_private_key(raw_data, passphrase=None, wrap_tags=True) + +def is_valid_crl(raw_data): + # If it loads correctly we're good, or return False + return load_crl(raw_data, wrap_tags=True) + +def is_valid_dh_parameters(raw_data): + # If it loads correctly we're good, or return False + return load_dh_parameters(raw_data, wrap_tags=True) + +def verify(pki): + if not pki: + return None + + if 'ca' in pki: + for name, ca_conf in pki['ca'].items(): + if 'certificate' in ca_conf: + if not is_valid_ca_certificate(ca_conf['certificate']): + raise ConfigError(f'Invalid certificate on CA certificate "{name}"') + + if 'private' in ca_conf and 'key' in ca_conf['private']: + private = ca_conf['private'] + protected = 'password_protected' in private + + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on CA certificate "{name}"') + + if 'crl' in ca_conf: + ca_crls = ca_conf['crl'] + if isinstance(ca_crls, str): + ca_crls = [ca_crls] + + for crl in ca_crls: + if not is_valid_crl(crl): + raise ConfigError(f'Invalid CRL on CA certificate "{name}"') + + if 'certificate' in pki: + for name, cert_conf in pki['certificate'].items(): + if 'certificate' in cert_conf: + if not is_valid_certificate(cert_conf['certificate']): + raise ConfigError(f'Invalid certificate on certificate "{name}"') + + if 'private' in cert_conf and 'key' in cert_conf['private']: + private = cert_conf['private'] + protected = 'password_protected' in private + + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on certificate "{name}"') + + if 'dh' in pki: + for name, dh_conf in pki['dh'].items(): + if 'parameters' in dh_conf: + if not is_valid_dh_parameters(dh_conf['parameters']): + raise ConfigError(f'Invalid DH parameters on "{name}"') + + if 'key_pair' in pki: + for name, key_conf in pki['key_pair'].items(): + if 'public' in key_conf and 'key' in key_conf['public']: + if not is_valid_public_key(key_conf['public']['key']): + raise ConfigError(f'Invalid public key on key-pair "{name}"') + + if 'private' in key_conf and 'key' in key_conf['private']: + private = key_conf['private'] + protected = 'password_protected' in private + if not is_valid_private_key(private['key'], protected): + raise ConfigError(f'Invalid private key on key-pair "{name}"') + + if 'x509' in pki: + if 'default' in pki['x509']: + default_values = pki['x509']['default'] + if 'country' in default_values: + country = default_values['country'] + if len(country) != 2 or not country.isalpha(): + raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + + return None + +def generate(pki): + if not pki: + return None + + return None + +def apply(pki): + if not pki: + return None + + 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_bfd.py b/src/conf_mode/protocols_bfd.py index dd70d6bab..348bae59f 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -102,12 +102,6 @@ def apply(bfd): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', bfd['new_frr_config']) frr_cfg.commit_configuration() - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if bfd['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration() - return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 74253c2d7..9ecfd07fe 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -57,6 +57,11 @@ def get_config(config=None): if not conf.exists(base): bgp.update({'deleted' : ''}) + if not vrf: + # We are running in the default VRF context, thus we can not delete + # our main BGP instance if there are dependent BGP VRF instances. + bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'], + key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) return bgp # We also need some additional information from the config, prefix-lists @@ -88,10 +93,19 @@ def verify_remote_as(peer_config, bgp_config): tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config) if tmp: return tmp + if 'v6only' in peer_config['interface']: + if 'remote_as' in peer_config['interface']['v6only']: + return peer_config['interface']['v6only']['remote_as'] + return None def verify(bgp): if not bgp or 'deleted' in bgp: + if 'dependent_vrfs' in bgp: + for vrf, vrf_options in bgp['dependent_vrfs'].items(): + if dict_search('protocols.bgp', vrf_options) != None: + raise ConfigError('Cannot delete default BGP instance, ' \ + 'dependent VRF instance(s) exist!') return None if 'local_as' not in bgp: @@ -267,15 +281,6 @@ def apply(bgp): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', bgp['frr_bgpd_config']) frr_cfg.commit_configuration(bgp_daemon) - # If FRR config is blank, re-run the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if bgp['frr_bgpd_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(bgp_daemon) - if bgp['frr_zebra_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(zebra_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index ef21e0055..d4c82249b 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -128,9 +128,11 @@ def verify(isis): raise ConfigError(f'Interface {interface} is not a member of VRF {vrf}!') # If md5 and plaintext-password set at the same time - if 'area_password' in isis: - if {'md5', 'plaintext_password'} <= set(isis['encryption']): - raise ConfigError('Can not use both md5 and plaintext-password for ISIS area-password!') + for password in ['area_password', 'domain_password']: + if password in isis: + if {'md5', 'plaintext_password'} <= set(isis[password]): + tmp = password.replace('_', '-') + raise ConfigError(f'Can use either md5 or plaintext-password for {tmp}!') # If one param from delay set, but not set others if 'spf_delay_ietf' in isis: @@ -147,7 +149,7 @@ def verify(isis): # If Redistribute set, but level don't set if 'redistribute' in isis: proc_level = isis.get('level','').replace('-','_') - for afi in ['ipv4']: + for afi in ['ipv4', 'ipv6']: if afi not in isis['redistribute']: continue @@ -196,7 +198,7 @@ def generate(isis): isis['protocol'] = 'isis' # required for frr/vrf.route-map.frr.tmpl isis['frr_zebra_config'] = render_to_string('frr/vrf.route-map.frr.tmpl', isis) - isis['frr_isisd_config'] = render_to_string('frr/isis.frr.tmpl', isis) + isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.tmpl', isis) return None def apply(isis): @@ -230,15 +232,6 @@ def apply(isis): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', isis['frr_isisd_config']) frr_cfg.commit_configuration(isis_daemon) - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if isis['frr_isisd_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(isis_daemon) - if isis['frr_zebra_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(zebra_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py new file mode 100755 index 000000000..12dacdba0 --- /dev/null +++ b/src/conf_mode/protocols_nhrp.py @@ -0,0 +1,122 @@ +#!/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 <http://www.gnu.org/licenses/>. + +from vyos.config import Config +from vyos.configdict import node_changed +from vyos.template import render +from vyos.util import process_named_running +from vyos.util import run +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +opennhrp_conf = '/run/opennhrp/opennhrp.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['protocols', 'nhrp'] + + nhrp = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel'], key_mangling=('-', '_')) + + if not conf.exists(base): + return nhrp + + nhrp['if_tunnel'] = conf.get_config_dict(['interfaces', 'tunnel'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + nhrp['profile_map'] = {} + profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + for name, profile_conf in profile.items(): + if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: + interfaces = profile_conf['bind']['tunnel'] + if isinstance(interfaces, str): + interfaces = [interfaces] + for interface in interfaces: + nhrp['profile_map'][interface] = name + + return nhrp + +def verify(nhrp): + if 'tunnel' in nhrp: + for name, nhrp_conf in nhrp['tunnel'].items(): + if not nhrp['if_tunnel'] or name not in nhrp['if_tunnel']: + raise ConfigError(f'Tunnel interface "{name}" does not exist') + + tunnel_conf = nhrp['if_tunnel'][name] + + if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre': + raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel') + + if 'remote' in tunnel_conf: + raise ConfigError(f'Tunnel "{name}" cannot have a remote address defined') + + if 'map' in nhrp_conf: + for map_name, map_conf in nhrp_conf['map'].items(): + if 'nbma_address' not in map_conf: + raise ConfigError(f'nbma-address missing on map {map_name} on tunnel {name}') + + if 'dynamic_map' in nhrp_conf: + for map_name, map_conf in nhrp_conf['dynamic_map'].items(): + if 'nbma_domain_name' not in map_conf: + raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') + return None + +def generate(nhrp): + render(opennhrp_conf, 'nhrp/opennhrp.conf.tmpl', nhrp) + return None + +def apply(nhrp): + if 'tunnel' in nhrp: + for tunnel, tunnel_conf in nhrp['tunnel'].items(): + if 'source_address' in tunnel_conf: + chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' + source_address = tunnel_conf['source_address'] + + chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 + if not chain_exists: + run(f'sudo iptables --new {chain}') + run(f'sudo iptables --append {chain} -p gre -s {source_address} -d 224.0.0.0/4 -j DROP') + run(f'sudo iptables --append {chain} -j RETURN') + run(f'sudo iptables --insert OUTPUT 2 -j {chain}') + + for tunnel in nhrp['del_tunnels']: + chain = f'VYOS_NHRP_{tunnel}_OUT_HOOK' + chain_exists = run(f'sudo iptables --check {chain} -j RETURN') == 0 + if chain_exists: + run(f'sudo iptables --delete OUTPUT -j {chain}') + run(f'sudo iptables --flush {chain}') + run(f'sudo iptables --delete-chain {chain}') + + action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' + run(f'systemctl {action} opennhrp') + 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_ospf.py b/src/conf_mode/protocols_ospf.py index 21eb8e447..78c1c82bd 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -211,15 +211,6 @@ def apply(ospf): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ospf['frr_ospfd_config']) frr_cfg.commit_configuration(ospf_daemon) - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if ospf['frr_ospfd_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(ospf_daemon) - if ospf['frr_zebra_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(zebra_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index 1964e9d34..fef0f509b 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -86,12 +86,6 @@ def apply(ospfv3): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ospfv3['new_frr_config']) frr_cfg.commit_configuration(frr_daemon) - # If FRR config is blank, re-run the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if ospfv3['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(frr_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index 907ac54ac..e56eb1f56 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -117,12 +117,6 @@ def apply(rip): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', rip['new_frr_config']) frr_cfg.commit_configuration(rip_daemon) - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if rip['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(rip_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index 44c080546..aaec5dacb 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -108,12 +108,6 @@ def apply(ripng): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', ripng['new_frr_config']) frr_cfg.commit_configuration(frr_daemon) - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if ripng['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(frr_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index d8f99efb8..947c8ab7a 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -90,12 +90,6 @@ def apply(rpki): frr_cfg.add_before(r'(ip prefix-list .*|route-map .*|line vty)', rpki['new_frr_config']) frr_cfg.commit_configuration(frr_daemon) - # If FRR config is blank, re-run the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if rpki['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(frr_daemon) - return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index 1d45cb71c..338247e30 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -107,12 +107,6 @@ def apply(static): frr_cfg.add_before(r'(interface .*|line vty)', static['new_frr_config']) frr_cfg.commit_configuration(static_daemon) - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if static['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(static_daemon) - # Save configuration to /run/frr/config/frr.conf frr.save_configuration() diff --git a/src/conf_mode/service_mdns-repeater.py b/src/conf_mode/service_mdns-repeater.py index 729518c96..c920920ed 100755 --- a/src/conf_mode/service_mdns-repeater.py +++ b/src/conf_mode/service_mdns-repeater.py @@ -16,10 +16,12 @@ import os +from json import loads from sys import exit from netifaces import ifaddresses, interfaces, AF_INET from vyos.config import Config +from vyos.ifconfig.vrrp import VRRP from vyos.template import render from vyos.util import call from vyos import ConfigError @@ -27,6 +29,7 @@ from vyos import airbag airbag.enable() config_file = r'/etc/default/mdns-repeater' +vrrp_running_file = '/run/mdns_vrrp_active' def get_config(config=None): if config: @@ -35,6 +38,9 @@ def get_config(config=None): conf = Config() base = ['service', 'mdns', 'repeater'] mdns = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + + if mdns: + mdns['vrrp_exists'] = conf.exists('high-availability vrrp') return mdns def verify(mdns): @@ -60,6 +66,18 @@ def verify(mdns): 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 @@ -68,6 +86,12 @@ def generate(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/mdns-repeater.tmpl', mdns) return None @@ -76,7 +100,21 @@ def apply(mdns): call('systemctl stop mdns-repeater.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('systemctl stop mdns-repeater.service') + return None + call('systemctl restart mdns-repeater.service') return None diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 65eb11ce3..9afcdd63e 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2019 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -40,11 +40,14 @@ def get_config(config=None): # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrived. default_interface_values = defaults(base + ['interface']) - # we deal with prefix defaults later on + # we deal with prefix, route defaults later on if 'prefix' in default_interface_values: del default_interface_values['prefix'] + if 'route' in default_interface_values: + del default_interface_values['route'] default_prefix_values = defaults(base + ['interface', 'prefix']) + default_route_values = defaults(base + ['interface', 'route']) if 'interface' in rtradv: for interface in rtradv['interface']: @@ -56,6 +59,11 @@ def get_config(config=None): rtradv['interface'][interface]['prefix'][prefix] = dict_merge( default_prefix_values, rtradv['interface'][interface]['prefix'][prefix]) + if 'route' in rtradv['interface'][interface]: + for route in rtradv['interface'][interface]['route']: + rtradv['interface'][interface]['route'][route] = dict_merge( + default_route_values, rtradv['interface'][interface]['route'][route]) + if 'name_server' in rtradv['interface'][interface]: # always use a list when dealing with nameservers - eases the template generation if isinstance(rtradv['interface'][interface]['name_server'], str): diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 3990e5735..23e45a5b7 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -54,6 +54,7 @@ default_config_data = { 'location' : '', 'description' : '', 'contact' : '', + 'route_table': 'False', 'trap_source': '', 'trap_targets': [], 'vyos_user': '', @@ -186,6 +187,9 @@ def get_config(): snmp['script_ext'].append(extension) + if conf.exists('oid-enable route-table'): + snmp['route_table'] = True + if conf.exists('vrf'): # Append key to dict but don't place it in the default dictionary. # This is required to make the override.conf.tmpl work until we diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py index 569010735..a960a4da3 100755 --- a/src/conf_mode/system-login-banner.py +++ b/src/conf_mode/system-login-banner.py @@ -22,11 +22,11 @@ from vyos import airbag airbag.enable() motd=""" -The programs included with the Debian GNU/Linux system are free software; +The programs included with the Debian/VyOS GNU/Linux system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. -Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent +Debian/VyOS GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. """ @@ -36,7 +36,7 @@ PRELOGIN_NET_FILE = r'/etc/issue.net' POSTLOGIN_FILE = r'/etc/motd' default_config_data = { - 'issue': 'Welcome to VyOS - \n \l\n', + 'issue': 'Welcome to VyOS - \\n \\l\n', 'issue_net': 'Welcome to VyOS\n', 'motd': motd } diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index da0fc2a25..f0b92aea8 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -43,12 +43,11 @@ radius_config_file = "/etc/pam_radius_auth.conf" def get_local_users(): """Return list of dynamically allocated users (see Debian Policy Manual)""" local_users = [] - for p in getpwall(): - username = p[0] - uid = getpwnam(username).pw_uid + for s_user in getpwall(): + uid = getpwnam(s_user.pw_name).pw_uid if uid in range(1000, 29999): - if username not in ['radius_user', 'radius_priv_user']: - local_users.append(username) + if s_user.pw_name not in ['radius_user', 'radius_priv_user']: + local_users.append(s_user.pw_name) return local_users @@ -104,7 +103,14 @@ def verify(login): 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 < 1000: + 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}"!') diff --git a/src/conf_mode/system-option.py b/src/conf_mode/system-option.py index 454611c55..55cf6b142 100755 --- a/src/conf_mode/system-option.py +++ b/src/conf_mode/system-option.py @@ -24,6 +24,7 @@ from vyos.config import Config from vyos.configdict import dict_merge from vyos.template import render from vyos.util import cmd +from vyos.util import is_systemd_service_running from vyos.validate import is_addr_assigned from vyos.xml import defaults from vyos import ConfigError @@ -114,7 +115,7 @@ def apply(options): if 'performance' in options: cmd('systemctl restart tuned.service') # wait until daemon has started before sending configuration - while (int(os.system('systemctl is-active --quiet tuned.service')) != 0): + while (not is_systemd_service_running('tuned.service')): sleep(0.250) cmd('tuned-adm profile network-{performance}'.format(**options)) else: diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py new file mode 100755 index 000000000..4f16d1ed6 --- /dev/null +++ b/src/conf_mode/system_sysctl.py @@ -0,0 +1,73 @@ +#!/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 <http://www.gnu.org/licenses/>. + +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.util import cmd +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +config_file = r'/run/sysctl/99-vyos-sysctl.conf' + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'sysctl'] + if not conf.exists(base): + return None + + sysctl = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + return sysctl + +def verify(sysctl): + return None + +def generate(sysctl): + if not sysctl: + if os.path.isfile(config_file): + os.unlink(config_file) + return None + + render(config_file, 'system/sysctl.conf.tmpl', sysctl) + return None + +def apply(sysctl): + if not sysctl: + return None + + # We silently ignore all errors + # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080 + cmd(f'sysctl -f {config_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/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 969266c30..d3065fc47 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# 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 @@ -14,54 +14,586 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import ipaddress import os from sys import exit +from time import sleep +from time import time from vyos.config import Config +from vyos.configdict import leaf_node_changed +from vyos.configverify import verify_interface_exists +from vyos.configdict import dict_merge +from vyos.ifconfig import Interface +from vyos.pki import encode_public_key +from vyos.pki import load_private_key +from vyos.pki import wrap_certificate +from vyos.pki import wrap_crl +from vyos.pki import wrap_public_key +from vyos.pki import wrap_private_key +from vyos.template import ip_from_cidr +from vyos.template import is_ipv4 +from vyos.template import is_ipv6 from vyos.template import render +from vyos.validate import is_ipv6_link_local from vyos.util import call from vyos.util import dict_search +from vyos.util import dict_search_args +from vyos.util import run +from vyos.xml import defaults from vyos import ConfigError from vyos import airbag -from pprint import pprint airbag.enable() +dhcp_wait_attempts = 2 +dhcp_wait_sleep = 1 + +swanctl_dir = '/etc/swanctl' +ipsec_conf = '/etc/ipsec.conf' +ipsec_secrets = '/etc/ipsec.secrets' +charon_conf = '/etc/strongswan.d/charon.conf' +charon_dhcp_conf = '/etc/strongswan.d/charon/dhcp.conf' +charon_radius_conf = '/etc/strongswan.d/charon/eap-radius.conf' +interface_conf = '/etc/strongswan.d/interfaces_use.conf' +swanctl_conf = f'{swanctl_dir}/swanctl.conf' + +default_install_routes = 'yes' + +vici_socket = '/var/run/charon.vici' + +CERT_PATH = f'{swanctl_dir}/x509/' +PUBKEY_PATH = f'{swanctl_dir}/pubkey/' +KEY_PATH = f'{swanctl_dir}/private/' +CA_PATH = f'{swanctl_dir}/x509ca/' +CRL_PATH = f'{swanctl_dir}/x509crl/' + +DHCP_BASE = '/var/lib/dhcp/dhclient' +DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_waiting' + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['vpn', 'nipsec'] + base = ['vpn', 'ipsec'] + l2tp_base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings'] if not conf.exists(base): return None # retrieve common dictionary keys - ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + # XXX: T2665: we must safely remove default values for tag nodes, those are + # added in a more fine grained way later on + del default_values['esp_group'] + del default_values['ike_group'] + del default_values['remote_access'] + ipsec = dict_merge(default_values, ipsec) + + if 'esp_group' in ipsec: + default_values = defaults(base + ['esp-group']) + for group in ipsec['esp_group']: + ipsec['esp_group'][group] = dict_merge(default_values, + ipsec['esp_group'][group]) + if 'ike_group' in ipsec: + default_values = defaults(base + ['ike-group']) + # proposal is a tag node which may come with individual defaults per node + if 'proposal' in default_values: + del default_values['proposal'] + + for group in ipsec['ike_group']: + ipsec['ike_group'][group] = dict_merge(default_values, + ipsec['ike_group'][group]) + + if 'proposal' in ipsec['ike_group'][group]: + default_values = defaults(base + ['ike-group', 'proposal']) + for proposal in ipsec['ike_group'][group]['proposal']: + ipsec['ike_group'][group]['proposal'][proposal] = dict_merge(default_values, + ipsec['ike_group'][group]['proposal'][proposal]) + + if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']: + default_values = defaults(base + ['remote-access', 'connection']) + for rw in ipsec['remote_access']['connection']: + ipsec['remote_access']['connection'][rw] = dict_merge(default_values, + ipsec['remote_access']['connection'][rw]) + + if 'remote_access' in ipsec and 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: + default_values = defaults(base + ['remote-access', 'radius', 'server']) + for server in ipsec['remote_access']['radius']['server']: + ipsec['remote_access']['radius']['server'][server] = dict_merge(default_values, + ipsec['remote_access']['radius']['server'][server]) + + ipsec['dhcp_no_address'] = {} + ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes + ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) + ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) + ipsec['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + tmp = conf.get_config_dict(l2tp_base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + if tmp: + ipsec['l2tp'] = tmp + l2tp_defaults = defaults(l2tp_base) + ipsec['l2tp'] = dict_merge(l2tp_defaults, ipsec['l2tp']) + ipsec['l2tp_outside_address'] = conf.return_value(['vpn', 'l2tp', 'remote-access', 'outside-address']) + ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024' + ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1' + return ipsec +def get_dhcp_address(iface): + addresses = Interface(iface).get_addr() + if not addresses: + return None + for address in addresses: + if not is_ipv6_link_local(address): + return ip_from_cidr(address) + return None + +def verify_pki_x509(pki, x509_conf): + if not pki or 'ca' not in pki or 'certificate' not in pki: + raise ConfigError(f'PKI is not configured') + + ca_cert_name = x509_conf['ca_certificate'] + cert_name = x509_conf['certificate'] + + if not dict_search_args(pki, 'ca', ca_cert_name, 'certificate'): + raise ConfigError(f'Missing CA certificate on specified PKI CA certificate "{ca_cert_name}"') + + if not dict_search_args(pki, 'certificate', cert_name, 'certificate'): + raise ConfigError(f'Missing certificate on specified PKI certificate "{cert_name}"') + + if not dict_search_args(pki, 'certificate', cert_name, 'private', 'key'): + raise ConfigError(f'Missing private key on specified PKI certificate "{cert_name}"') + + return True + +def verify_pki_rsa(pki, rsa_conf): + if not pki or 'key_pair' not in pki: + raise ConfigError(f'PKI is not configured') + + local_key = rsa_conf['local_key'] + remote_key = rsa_conf['remote_key'] + + if not dict_search_args(pki, 'key_pair', local_key, 'private', 'key'): + raise ConfigError(f'Missing private key on specified local-key "{local_key}"') + + if not dict_search_args(pki, 'key_pair', remote_key, 'public', 'key'): + raise ConfigError(f'Missing public key on specified remote-key "{remote_key}"') + + return True + def verify(ipsec): if not ipsec: return None + if 'interfaces' in ipsec : + for ifname in ipsec['interface']: + verify_interface_exists(ifname) + + if 'l2tp' in ipsec: + if 'esp_group' in ipsec['l2tp']: + if 'esp_group' not in ipsec or ipsec['l2tp']['esp_group'] not in ipsec['esp_group']: + raise ConfigError(f"Invalid esp-group on L2TP remote-access config") + + if 'ike_group' in ipsec['l2tp']: + if 'ike_group' not in ipsec or ipsec['l2tp']['ike_group'] not in ipsec['ike_group']: + raise ConfigError(f"Invalid ike-group on L2TP remote-access config") + + if 'authentication' not in ipsec['l2tp']: + raise ConfigError(f'Missing authentication settings on L2TP remote-access config') + + if 'mode' not in ipsec['l2tp']['authentication']: + raise ConfigError(f'Missing authentication mode on L2TP remote-access config') + + if not ipsec['l2tp_outside_address']: + raise ConfigError(f'Missing outside-address on L2TP remote-access config') + + if ipsec['l2tp']['authentication']['mode'] == 'pre-shared-secret': + if 'pre_shared_secret' not in ipsec['l2tp']['authentication']: + raise ConfigError(f'Missing pre shared secret on L2TP remote-access config') + + if ipsec['l2tp']['authentication']['mode'] == 'x509': + if 'x509' not in ipsec['l2tp']['authentication']: + raise ConfigError(f'Missing x509 settings on L2TP remote-access config') + + x509 = ipsec['l2tp']['authentication']['x509'] + + if 'ca_certificate' not in x509 or 'certificate' not in x509: + raise ConfigError(f'Missing x509 certificates on L2TP remote-access config') + + verify_pki_x509(ipsec['pki'], x509) + + if 'profile' in ipsec: + for profile, profile_conf in ipsec['profile'].items(): + if 'esp_group' in profile_conf: + if 'esp_group' not in ipsec or profile_conf['esp_group'] not in ipsec['esp_group']: + raise ConfigError(f"Invalid esp-group on {profile} profile") + else: + raise ConfigError(f"Missing esp-group on {profile} profile") + + if 'ike_group' in profile_conf: + if 'ike_group' not in ipsec or profile_conf['ike_group'] not in ipsec['ike_group']: + raise ConfigError(f"Invalid ike-group on {profile} profile") + else: + raise ConfigError(f"Missing ike-group on {profile} profile") + + if 'authentication' not in profile_conf: + raise ConfigError(f"Missing authentication on {profile} profile") + + if 'remote_access' in ipsec: + if 'connection' in ipsec['remote_access']: + for name, ra_conf in ipsec['remote_access']['connection'].items(): + if 'esp_group' in ra_conf: + if 'esp_group' not in ipsec or ra_conf['esp_group'] not in ipsec['esp_group']: + raise ConfigError(f"Invalid esp-group on {name} remote-access config") + else: + raise ConfigError(f"Missing esp-group on {name} remote-access config") + + if 'ike_group' in ra_conf: + if 'ike_group' not in ipsec or ra_conf['ike_group'] not in ipsec['ike_group']: + raise ConfigError(f"Invalid ike-group on {name} remote-access config") + + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError('IPSec remote-access connections requires IKEv2!') + + else: + raise ConfigError(f"Missing ike-group on {name} remote-access config") + + if 'authentication' not in ra_conf: + raise ConfigError(f"Missing authentication on {name} remote-access config") + + if ra_conf['authentication']['server_mode'] == 'x509': + if 'x509' not in ra_conf['authentication']: + raise ConfigError(f"Missing x509 settings on {name} remote-access config") + + x509 = ra_conf['authentication']['x509'] + + if 'ca_certificate' not in x509 or 'certificate' not in x509: + raise ConfigError(f"Missing x509 certificates on {name} remote-access config") + + verify_pki_x509(ipsec['pki'], x509) + elif ra_conf['authentication']['server_mode'] == 'pre-shared-secret': + if 'pre_shared_secret' not in ra_conf['authentication']: + raise ConfigError(f"Missing pre-shared-key on {name} remote-access config") + + + if 'client_mode' in ra_conf['authentication']: + if ra_conf['authentication']['client_mode'] == 'eap-radius': + if 'radius' not in ipsec['remote_access'] or 'server' not in ipsec['remote_access']['radius'] or len(ipsec['remote_access']['radius']['server']) == 0: + raise ConfigError('RADIUS authentication requires at least one server') + + if 'pool' in ra_conf: + if 'dhcp' in ra_conf['pool'] and len(ra_conf['pool']) > 1: + raise ConfigError(f'Can not use both DHCP and a predefined address pool for "{name}"!') + + for pool in ra_conf['pool']: + if pool == 'dhcp': + if dict_search('remote_access.dhcp.server', ipsec) == None: + raise ConfigError('IPSec DHCP server is not configured!') + + elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']: + raise ConfigError(f'Requested pool "{pool}" does not exist!') + + if 'pool' in ipsec['remote_access']: + for pool, pool_config in ipsec['remote_access']['pool'].items(): + if 'prefix' not in pool_config: + raise ConfigError(f'Missing madatory prefix option for pool "{pool}"!') + + if 'name_server' in pool_config: + if len(pool_config['name_server']) > 2: + raise ConfigError(f'Only two name-servers are supported for remote-access pool "{pool}"!') + + for ns in pool_config['name_server']: + v4_addr_and_ns = is_ipv4(ns) and not is_ipv4(pool_config['prefix']) + v6_addr_and_ns = is_ipv6(ns) and not is_ipv6(pool_config['prefix']) + if v4_addr_and_ns or v6_addr_and_ns: + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and name-server adresses!') + + if 'exclude' in pool_config: + for exclude in pool_config['exclude']: + v4_addr_and_exclude = is_ipv4(exclude) and not is_ipv4(pool_config['prefix']) + v6_addr_and_exclude = is_ipv6(exclude) and not is_ipv6(pool_config['prefix']) + if v4_addr_and_exclude or v6_addr_and_exclude: + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and exclude prefixes!') + + if 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: + for server, server_config in ipsec['remote_access']['radius']['server'].items(): + if 'key' not in server_config: + raise ConfigError(f'Missing RADIUS secret key for server "{server}"') + + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + has_default_esp = False + if 'default_esp_group' in peer_conf: + has_default_esp = True + if 'esp_group' not in ipsec or peer_conf['default_esp_group'] not in ipsec['esp_group']: + raise ConfigError(f"Invalid esp-group on site-to-site peer {peer}") + + if 'ike_group' in peer_conf: + if 'ike_group' not in ipsec or peer_conf['ike_group'] not in ipsec['ike_group']: + raise ConfigError(f"Invalid ike-group on site-to-site peer {peer}") + else: + raise ConfigError(f"Missing ike-group on site-to-site peer {peer}") + + if 'authentication' not in peer_conf or 'mode' not in peer_conf['authentication']: + raise ConfigError(f"Missing authentication on site-to-site peer {peer}") + + if peer_conf['authentication']['mode'] == 'x509': + if 'x509' not in peer_conf['authentication']: + raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}") + + x509 = peer_conf['authentication']['x509'] + + if 'ca_certificate' not in x509 or 'certificate' not in x509: + raise ConfigError(f"Missing x509 certificates on site-to-site peer {peer}") + + verify_pki_x509(ipsec['pki'], x509) + elif peer_conf['authentication']['mode'] == 'rsa': + if 'rsa' not in peer_conf['authentication']: + raise ConfigError(f"Missing RSA settings on site-to-site peer {peer}") + + rsa = peer_conf['authentication']['rsa'] + + if 'local_key' not in rsa: + raise ConfigError(f"Missing RSA local-key on site-to-site peer {peer}") + + if 'remote_key' not in rsa: + raise ConfigError(f"Missing RSA remote-key on site-to-site peer {peer}") + + verify_pki_rsa(ipsec['pki'], rsa) + + if 'local_address' not in peer_conf and 'dhcp_interface' not in peer_conf: + raise ConfigError(f"Missing local-address or dhcp-interface on site-to-site peer {peer}") + + if 'dhcp_interface' in peer_conf: + dhcp_interface = peer_conf['dhcp_interface'] + + verify_interface_exists(dhcp_interface) + + if not os.path.exists(f'{DHCP_BASE}_{dhcp_interface}.conf'): + raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}") + + address = get_dhcp_address(dhcp_interface) + count = 0 + while not address and count < dhcp_wait_attempts: + address = get_dhcp_address(dhcp_interface) + count += 1 + sleep(dhcp_wait_sleep) + + if not address: + ipsec['dhcp_no_address'][peer] = dhcp_interface + print(f"Failed to get address from dhcp-interface on site-to-site peer {peer} -- skipped") + continue + + if 'vti' in peer_conf: + if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: + raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") + + if 'bind' in peer_conf['vti']: + vti_interface = peer_conf['vti']['bind'] + if not os.path.exists(f'/sys/class/net/{vti_interface}'): + raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') + + if 'vti' not in peer_conf and 'tunnel' not in peer_conf: + raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}") + + if 'tunnel' in peer_conf: + for tunnel, tunnel_conf in peer_conf['tunnel'].items(): + if 'esp_group' not in tunnel_conf and not has_default_esp: + raise ConfigError(f"Missing esp-group on tunnel {tunnel} for site-to-site peer {peer}") + + esp_group_name = tunnel_conf['esp_group'] if 'esp_group' in tunnel_conf else peer_conf['default_esp_group'] + + if esp_group_name not in ipsec['esp_group']: + raise ConfigError(f"Invalid esp-group on tunnel {tunnel} for site-to-site peer {peer}") + + esp_group = ipsec['esp_group'][esp_group_name] + + if 'mode' in esp_group and esp_group['mode'] == 'transport': + if 'protocol' in tunnel_conf and ((peer in ['any', '0.0.0.0']) or ('local_address' not in peer_conf or peer_conf['local_address'] in ['any', '0.0.0.0'])): + raise ConfigError(f"Fixed local-address or peer required when a protocol is defined with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}") + + if ('local' in tunnel_conf and 'prefix' in tunnel_conf['local']) or ('remote' in tunnel_conf and 'prefix' in tunnel_conf['remote']): + raise ConfigError(f"Local/remote prefix cannot be used with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}") + +def cleanup_pki_files(): + for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]: + if not os.path.exists(path): + continue + for file in os.listdir(path): + file_path = os.path.join(path, file) + if os.path.isfile(file_path): + os.unlink(file_path) + +def generate_pki_files_x509(pki, x509_conf): + ca_cert_name = x509_conf['ca_certificate'] + ca_cert_data = dict_search_args(pki, 'ca', ca_cert_name, 'certificate') + ca_cert_crls = dict_search_args(pki, 'ca', ca_cert_name, 'crl') or [] + crl_index = 1 + + cert_name = x509_conf['certificate'] + cert_data = dict_search_args(pki, 'certificate', cert_name, 'certificate') + key_data = dict_search_args(pki, 'certificate', cert_name, 'private', 'key') + protected = 'passphrase' in x509_conf + + with open(os.path.join(CA_PATH, f'{ca_cert_name}.pem'), 'w') as f: + f.write(wrap_certificate(ca_cert_data)) + + for crl in ca_cert_crls: + with open(os.path.join(CRL_PATH, f'{ca_cert_name}_{crl_index}.pem'), 'w') as f: + f.write(wrap_crl(crl)) + crl_index += 1 + + with open(os.path.join(CERT_PATH, f'{cert_name}.pem'), 'w') as f: + f.write(wrap_certificate(cert_data)) + + with open(os.path.join(KEY_PATH, f'x509_{cert_name}.pem'), 'w') as f: + f.write(wrap_private_key(key_data, protected)) + +def generate_pki_files_rsa(pki, rsa_conf): + local_key_name = rsa_conf['local_key'] + local_key_data = dict_search_args(pki, 'key_pair', local_key_name, 'private', 'key') + protected = 'passphrase' in rsa_conf + remote_key_name = rsa_conf['remote_key'] + remote_key_data = dict_search_args(pki, 'key_pair', remote_key_name, 'public', 'key') + + local_key = load_private_key(local_key_data, rsa_conf['passphrase'] if protected else None) + + with open(os.path.join(KEY_PATH, f'rsa_{local_key_name}.pem'), 'w') as f: + f.write(wrap_private_key(local_key_data, protected)) + + with open(os.path.join(PUBKEY_PATH, f'{local_key_name}.pem'), 'w') as f: + f.write(encode_public_key(local_key.public_key())) + + with open(os.path.join(PUBKEY_PATH, f'{remote_key_name}.pem'), 'w') as f: + f.write(wrap_public_key(remote_key_data)) + def generate(ipsec): + cleanup_pki_files() + if not ipsec: - return None + for config_file in [ipsec_conf, ipsec_secrets, charon_dhcp_conf, + charon_radius_conf, interface_conf, swanctl_conf]: + if os.path.isfile(config_file): + os.unlink(config_file) + render(charon_conf, 'ipsec/charon.tmpl', {'install_routes': default_install_routes}) + return - return ipsec + if ipsec['dhcp_no_address']: + with open(DHCP_HOOK_IFLIST, 'w') as f: + f.write(" ".join(ipsec['dhcp_no_address'].values())) + + for path in [swanctl_dir, CERT_PATH, CA_PATH, CRL_PATH, PUBKEY_PATH]: + if not os.path.exists(path): + os.mkdir(path, mode=0o755) + + if not os.path.exists(KEY_PATH): + os.mkdir(KEY_PATH, mode=0o700) + + if 'l2tp' in ipsec: + if 'authentication' in ipsec['l2tp'] and 'x509' in ipsec['l2tp']['authentication']: + generate_pki_files_x509(ipsec['pki'], ipsec['l2tp']['authentication']['x509']) + + if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']: + for rw, rw_conf in ipsec['remote_access']['connection'].items(): + + if 'authentication' in rw_conf and 'x509' in rw_conf['authentication']: + generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509']) + + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + if peer in ipsec['dhcp_no_address']: + continue + + if peer_conf['authentication']['mode'] == 'x509': + generate_pki_files_x509(ipsec['pki'], peer_conf['authentication']['x509']) + elif peer_conf['authentication']['mode'] == 'rsa': + generate_pki_files_rsa(ipsec['pki'], peer_conf['authentication']['rsa']) + + local_ip = '' + if 'local_address' in peer_conf: + local_ip = peer_conf['local_address'] + elif 'dhcp_interface' in peer_conf: + local_ip = get_dhcp_address(peer_conf['dhcp_interface']) + + ipsec['site_to_site']['peer'][peer]['local_address'] = local_ip + + if 'tunnel' in peer_conf: + for tunnel, tunnel_conf in peer_conf['tunnel'].items(): + local_prefixes = dict_search_args(tunnel_conf, 'local', 'prefix') + remote_prefixes = dict_search_args(tunnel_conf, 'remote', 'prefix') + + if not local_prefixes or not remote_prefixes: + continue + + passthrough = [] + + for local_prefix in local_prefixes: + for remote_prefix in remote_prefixes: + local_net = ipaddress.ip_network(local_prefix) + remote_net = ipaddress.ip_network(remote_prefix) + if local_net.overlaps(remote_net): + passthrough.append(local_prefix) + + ipsec['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough + + + render(ipsec_conf, 'ipsec/ipsec.conf.tmpl', ipsec) + render(ipsec_secrets, 'ipsec/ipsec.secrets.tmpl', ipsec) + render(charon_conf, 'ipsec/charon.tmpl', ipsec) + render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.tmpl', ipsec) + render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.tmpl', ipsec) + render(interface_conf, 'ipsec/interfaces_use.conf.tmpl', ipsec) + render(swanctl_conf, 'ipsec/swanctl.conf.tmpl', ipsec) + +def resync_nhrp(ipsec): + if ipsec and not ipsec['nhrp_exists']: + return + + tmp = run('/usr/libexec/vyos/conf_mode/protocols_nhrp.py') + if tmp > 0: + print('ERROR: failed to reapply NHRP settings!') + +def wait_for_vici_socket(timeout=5, sleep_interval=0.1): + start_time = time() + test_command = f'sudo socat -u OPEN:/dev/null UNIX-CONNECT:{vici_socket}' + while True: + if (start_time + timeout) < time(): + return None + result = run(test_command) + if result == 0: + return True + sleep(sleep_interval) def apply(ipsec): if not ipsec: - return None + call('sudo ipsec stop') + else: + call('sudo ipsec restart') + call('sudo ipsec rereadall') + call('sudo ipsec reload') + + if wait_for_vici_socket(): + call('sudo swanctl -q') - pprint(ipsec) + resync_nhrp(ipsec) if __name__ == '__main__': try: - c = get_config() - verify(c) - generate(c) - apply(c) + ipsec = get_config() + verify(ipsec) + generate(ipsec) + apply(ipsec) except ConfigError as e: print(e) exit(1) diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index e970d2ef5..9c52f77ca 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -20,7 +20,6 @@ import re from copy import deepcopy from stat import S_IRUSR, S_IWUSR, S_IRGRP from sys import exit -from time import sleep from ipaddress import ip_network diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index 2986c3458..f6db196dc 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -19,9 +19,11 @@ from sys import exit from vyos.config import Config from vyos.configdict import dict_merge -from vyos.xml import defaults +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call +from vyos.xml import defaults from vyos import ConfigError from crypt import crypt, mksalt, METHOD_SHA512 @@ -50,6 +52,10 @@ def get_config(): default_values = defaults(base) ocserv = dict_merge(default_values, ocserv) + if ocserv: + ocserv['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + return ocserv def verify(ocserv): @@ -72,14 +78,36 @@ def verify(ocserv): raise ConfigError('openconnect authentication credentials required') # Check ssl - if "ssl" in ocserv: - req_cert = ['cert_file', 'key_file'] - for cert in req_cert: - if not cert in ocserv["ssl"]: - raise ConfigError('openconnect ssl {0} required'.format(cert.replace('_', '-'))) - else: + if 'ssl' not in ocserv: raise ConfigError('openconnect ssl required') + if not ocserv['pki'] or 'certificate' not in ocserv['pki']: + raise ConfigError('PKI not configured') + + ssl = ocserv['ssl'] + if 'certificate' not in ssl: + raise ConfigError('openconnect ssl certificate required') + + cert_name = ssl['certificate'] + + if cert_name not in ocserv['pki']['certificate']: + raise ConfigError('Invalid openconnect ssl certificate') + + cert = ocserv['pki']['certificate'][cert_name] + + if 'certificate' not in cert: + raise ConfigError('Missing certificate in PKI') + + if 'private' not in cert or 'key' not in cert['private']: + raise ConfigError('Missing private key in PKI') + + if 'ca_certificate' in ssl: + if 'ca' not in ocserv['pki']: + raise ConfigError('PKI not configured') + + if ssl['ca_certificate'] not in ocserv['pki']['ca']: + raise ConfigError('Invalid openconnect ssl CA certificate') + # Check network settings if "network_settings" in ocserv: if "push_route" in ocserv["network_settings"]: @@ -109,6 +137,29 @@ def generate(ocserv): # Render local users render(ocserv_passwd, 'ocserv/ocserv_passwd.tmpl', ocserv["authentication"]["local_users"]) + if "ssl" in ocserv: + cert_file_path = os.path.join(cfg_dir, 'cert.pem') + cert_key_path = os.path.join(cfg_dir, 'cert.key') + ca_cert_file_path = os.path.join(cfg_dir, 'ca.pem') + + if 'certificate' in ocserv['ssl']: + cert_name = ocserv['ssl']['certificate'] + pki_cert = ocserv['pki']['certificate'][cert_name] + + 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 ocserv['ssl']: + ca_name = ocserv['ssl']['ca_certificate'] + pki_ca_cert = ocserv['pki']['ca'][ca_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + # Render config render(ocserv_conf, 'ocserv/ocserv_config.tmpl', ocserv) diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 47367f125..d1a71a5ad 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -21,6 +21,8 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict from vyos.configverify import verify_accel_ppp_base_service +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.util import call from vyos.util import dict_search @@ -28,6 +30,7 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +cfg_dir = '/run/accel-pppd' sstp_conf = '/run/accel-pppd/sstp.conf' sstp_chap_secrets = '/run/accel-pppd/sstp.chap-secrets' @@ -42,6 +45,11 @@ def get_config(config=None): # retrieve common dictionary keys sstp = get_accel_dict(conf, base, sstp_chap_secrets) + + if sstp: + sstp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + return sstp def verify(sstp): @@ -56,31 +64,59 @@ def verify(sstp): # # SSL certificate checks # - tmp = dict_search('ssl.ca_cert_file', sstp) - if not tmp: - raise ConfigError(f'SSL CA certificate file required!') - else: - if not os.path.isfile(tmp): - raise ConfigError(f'SSL CA certificate "{tmp}" does not exist!') + if not sstp['pki']: + raise ConfigError('PKI is not configured') - tmp = dict_search('ssl.cert_file', sstp) - if not tmp: - raise ConfigError(f'SSL public key file required!') - else: - if not os.path.isfile(tmp): - raise ConfigError(f'SSL public key "{tmp}" does not exist!') + if 'ssl' not in sstp: + raise ConfigError('SSL missing on SSTP config') - tmp = dict_search('ssl.key_file', sstp) - if not tmp: - raise ConfigError(f'SSL private key file required!') - else: - if not os.path.isfile(tmp): - raise ConfigError(f'SSL private key "{tmp}" does not exist!') + ssl = sstp['ssl'] + + if 'ca_certificate' not in ssl: + raise ConfigError('SSL CA certificate missing on SSTP config') + + if 'certificate' not in ssl: + raise ConfigError('SSL certificate missing on SSTP config') + + cert_name = ssl['certificate'] + + if ssl['ca_certificate'] not in sstp['pki']['ca']: + raise ConfigError('Invalid CA certificate on SSTP config') + + if cert_name not in sstp['pki']['certificate']: + raise ConfigError('Invalid certificate on SSTP config') + + pki_cert = sstp['pki']['certificate'][cert_name] + + if 'private' not in pki_cert or 'key' not in pki_cert['private']: + raise ConfigError('Missing private key for certificate on SSTP config') + + if 'password_protected' in pki_cert['private']: + raise ConfigError('Encrypted private key is not supported on SSTP config') def generate(sstp): if not sstp: return None + cert_file_path = os.path.join(cfg_dir, 'sstp-cert.pem') + cert_key_path = os.path.join(cfg_dir, 'sstp-cert.key') + ca_cert_file_path = os.path.join(cfg_dir, 'sstp-ca.pem') + + cert_name = sstp['ssl']['certificate'] + pki_cert = sstp['pki']['certificate'][cert_name] + + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + with open(cert_key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) + + ca_cert_name = sstp['ssl']['ca_certificate'] + pki_ca = sstp['pki']['ca'][ca_cert_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca['certificate'])) + # accel-cmd reload doesn't work so any change results in a restart of the daemon render(sstp_conf, 'accel-ppp/sstp.config.tmpl', sstp) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index a39da8991..c1cfc1dcb 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -18,6 +18,7 @@ import os from sys import exit from json import loads +from tempfile import NamedTemporaryFile from vyos.config import Config from vyos.configdict import node_changed @@ -28,6 +29,8 @@ from vyos.util import call from vyos.util import cmd from vyos.util import dict_search from vyos.util import get_interface_config +from vyos.util import popen +from vyos.util import run from vyos import ConfigError from vyos import frr from vyos import airbag @@ -125,11 +128,17 @@ def verify(vrf): return None + def generate(vrf): render(config_file, 'vrf/vrf.conf.tmpl', vrf) vrf['new_frr_config'] = render_to_string('frr/vrf.frr.tmpl', vrf) + # Render nftables zones config + vrf['nft_vrf_zones'] = NamedTemporaryFile().name + render(vrf['nft_vrf_zones'], 'firewall/nftables-vrf-zones.tmpl', vrf) + return None + def apply(vrf): # Documentation # @@ -141,7 +150,7 @@ def apply(vrf): # set the default VRF global behaviour bind_all = '0' - if 'bind_to_all' in vrf: + if 'bind-to-all' in vrf: bind_all = '1' call(f'sysctl -wq net.ipv4.tcp_l3mdev_accept={bind_all}') call(f'sysctl -wq net.ipv4.udp_l3mdev_accept={bind_all}') @@ -151,8 +160,19 @@ def apply(vrf): call(f'ip -4 route del vrf {tmp} unreachable default metric 4278198272') call(f'ip -6 route del vrf {tmp} unreachable default metric 4278198272') call(f'ip link delete dev {tmp}') + # Remove nftables conntrack zone map item + nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' + cmd(f'nft {nft_del_element}') if 'name' in vrf: + # Separate VRFs in conntrack table + # check if table already exists + _, err = popen('nft list table inet vrf_zones') + # If not, create a table + if err: + cmd(f'nft -f {vrf["nft_vrf_zones"]}') + os.unlink(vrf['nft_vrf_zones']) + for name, config in vrf['name'].items(): table = config['table'] @@ -182,6 +202,9 @@ def apply(vrf): # reconfiguration. state = 'down' if 'disable' in config else 'up' vrf_if.set_admin_state(state) + # Add nftables conntrack zone map item + nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' + cmd(f'nft {nft_add_element}') # Linux routing uses rules to find tables - routing targets are then # looked up in those tables. If the lookup got a matching route, the @@ -214,22 +237,25 @@ def apply(vrf): # clean out l3mdev-table rule if present if 1000 in [r.get('priority') for r in list_rules() if r.get('priority') == 1000]: call(f'ip {af} rule del pref 1000') - - # add configuration to FRR - frr_cfg = frr.FRRConfig() - frr_cfg.load_configuration(frr_daemon) - frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') - frr_cfg.add_before(r'(interface .*|line vty)', vrf['new_frr_config']) - frr_cfg.commit_configuration(frr_daemon) - - # If FRR config is blank, rerun the blank commit x times due to frr-reload - # behavior/bug not properly clearing out on one commit. - if vrf['new_frr_config'] == '': - for a in range(5): - frr_cfg.commit_configuration(frr_daemon) - - # Save configuration to /run/frr/config/frr.conf - frr.save_configuration() + # Remove VRF zones table from nftables + tmp = run('nft list table inet vrf_zones') + if tmp == 0: + cmd('nft delete table inet vrf_zones') + + # T3694: Somehow we hit a priority inversion here as we need to remove the + # VRF assigned VNI before we can remove a BGP bound VRF instance. Maybe + # move this to an individual helper script that set's up the VNI for the + # given VRF after any routing protocol. + # + # # add configuration to FRR + # frr_cfg = frr.FRRConfig() + # frr_cfg.load_configuration(frr_daemon) + # frr_cfg.modify_section(f'^vrf [a-zA-Z-]*$', '') + # frr_cfg.add_before(r'(interface .*|line vty)', vrf['new_frr_config']) + # frr_cfg.commit_configuration(frr_daemon) + # + # # Save configuration to /run/frr/config/frr.conf + # frr.save_configuration() return None diff --git a/src/conf_mode/vyos_cert.py b/src/conf_mode/vyos_cert.py deleted file mode 100755 index dc7c64684..000000000 --- a/src/conf_mode/vyos_cert.py +++ /dev/null @@ -1,147 +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 <http://www.gnu.org/licenses/>. -# -# - -import sys -import os -import tempfile -import pathlib -import ssl - -import vyos.defaults -from vyos.config import Config -from vyos import ConfigError -from vyos.util import cmd - -from vyos import airbag -airbag.enable() - -vyos_conf_scripts_dir = vyos.defaults.directories['conf_mode'] - -# XXX: this model will need to be extended for tag nodes -dependencies = [ - 'https.py', -] - -def status_self_signed(cert_data): -# check existence and expiration date - path = pathlib.Path(cert_data['conf']) - if not path.is_file(): - return False - path = pathlib.Path(cert_data['crt']) - if not path.is_file(): - return False - path = pathlib.Path(cert_data['key']) - if not path.is_file(): - return False - - # check if certificate is 1/2 past lifetime, with openssl -checkend - end_days = int(cert_data['lifetime']) - end_seconds = int(0.5*60*60*24*end_days) - checkend_cmd = 'openssl x509 -checkend {end} -noout -in {crt}'.format(end=end_seconds, **cert_data) - try: - cmd(checkend_cmd, message='Called process error') - return True - except OSError as err: - if err.errno == 1: - return False - print(err) - # XXX: This seems wrong to continue on failure - # implicitely returning None - -def generate_self_signed(cert_data): - san_config = None - - if ssl.OPENSSL_VERSION_INFO < (1, 1, 1, 0, 0): - san_config = tempfile.NamedTemporaryFile() - with open(san_config.name, 'w') as fd: - fd.write('[req]\n') - fd.write('distinguished_name=req\n') - fd.write('[san]\n') - fd.write('subjectAltName=DNS:vyos\n') - - openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' - '-newkey rsa:4096 -keyout {key} -out {crt} ' - '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' - '-extensions san -config {san_conf}' - ''.format(san_conf=san_config.name, - **cert_data)) - - else: - openssl_req_cmd = ('openssl req -x509 -nodes -days {lifetime} ' - '-newkey rsa:4096 -keyout {key} -out {crt} ' - '-subj "/O=Sentrium/OU=VyOS/CN=vyos" ' - '-addext "subjectAltName=DNS:vyos"' - ''.format(**cert_data)) - - try: - cmd(openssl_req_cmd, message='Called process error') - except OSError as err: - print(err) - # XXX: seems wrong to ignore the failure - - os.chmod('{key}'.format(**cert_data), 0o400) - - with open('{conf}'.format(**cert_data), 'w') as f: - f.write('ssl_certificate {crt};\n'.format(**cert_data)) - f.write('ssl_certificate_key {key};\n'.format(**cert_data)) - - if san_config: - san_config.close() - -def get_config(config=None): - vyos_cert = vyos.defaults.vyos_cert_data - - if config: - conf = config - else: - conf = Config() - if not conf.exists('service https certificates system-generated-certificate'): - return None - else: - conf.set_level('service https certificates system-generated-certificate') - - if conf.exists('lifetime'): - lifetime = conf.return_value('lifetime') - vyos_cert['lifetime'] = lifetime - - return vyos_cert - -def verify(vyos_cert): - return None - -def generate(vyos_cert): - if vyos_cert is None: - return None - - if not status_self_signed(vyos_cert): - generate_self_signed(vyos_cert) - -def apply(vyos_cert): - for dep in dependencies: - command = '{0}/{1}'.format(vyos_conf_scripts_dir, dep) - cmd(command, raising=ConfigError) - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - sys.exit(1) |