Page Menu
Home
VyOS Platform
Search
Configure Global Search
Log In
Files
F117521199
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
48 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py
index 25d7fd50c..833ac1fbf 100644
--- a/python/vyos/defaults.py
+++ b/python/vyos/defaults.py
@@ -1,100 +1,101 @@
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
import os
base_dir = '/usr/libexec/vyos'
directories = {
'base' : f'{base_dir}',
'data' : '/usr/share/vyos/',
'conf_mode' : f'{base_dir}/conf_mode',
'op_mode' : f'{base_dir}/op_mode',
'services' : f'{base_dir}/services',
'config' : '/opt/vyatta/etc/config',
'migrate' : '/opt/vyatta/etc/config-migrate/migrate',
'activate' : f'{base_dir}/activate',
'log' : '/var/log/vyatta',
'templates' : '/usr/share/vyos/templates/',
'certbot' : '/config/auth/letsencrypt',
'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/',
'api_client_op': f'{base_dir}/services/api/graphql/graphql/client_op/',
'api_templates': f'{base_dir}/services/api/graphql/session/templates/',
'vyos_udev_dir' : '/run/udev/vyos',
'isc_dhclient_dir' : '/run/dhclient',
'dhcp6_client_dir' : '/run/dhcp6c',
'vyos_configdir' : '/opt/vyatta/config',
'completion_dir' : f'{base_dir}/completion',
'ca_certificates' : '/usr/local/share/ca-certificates/vyos',
'podman_storage' : '/usr/lib/live/mount/persistence/container/storage',
'ppp_nexthop_dir' : '/run/ppp_nexthop',
'proto_path' : '/usr/share/vyos/vyconf',
'vyconf_session_dir' : f'{base_dir}/vyconf/session'
}
systemd_services = {
'haproxy' : 'haproxy.service',
+ 'openconnect': 'ocserv.service',
'syslog' : 'syslog.service',
'snmpd' : 'snmpd.service',
}
internal_ports = {
'certbot_haproxy' : 65080, # Certbot running behing haproxy
}
config_files = {
'sshd_user_ca' : '/run/sshd/trusted_user_ca',
}
config_status = '/tmp/vyos-config-status'
api_config_state = '/run/http-api-state'
frr_debug_enable = '/tmp/vyos.frr.debug'
static_route_dhcp_interfaces_path = '/tmp/static_dhcp_interfaces'
vyos_configd_socket_path = 'ipc:///run/vyos-configd.sock'
cfg_group = 'vyattacfg'
cfg_vintage = 'vyos'
commit_lock = os.path.join(directories['vyos_configdir'], '.lock')
component_version_json = os.path.join(directories['data'], 'component-versions.json')
config_default = os.path.join(directories['data'], 'config.boot.default')
rt_symbolic_names = {
# Standard routing tables for Linux & reserved IDs for VyOS
'default': 253, # Confusingly, a final fallthru, not the default.
'main': 254, # The actual global table used by iproute2 unless told otherwise.
'local': 255, # Special kernel loopback table.
}
rt_global_vrf = rt_symbolic_names['main']
rt_global_table = rt_symbolic_names['main']
vyconfd_conf = '/etc/vyos/vyconfd.conf'
DEFAULT_COMMIT_CONFIRM_MINUTES = 10
commit_hooks = {'pre': '/etc/commit/pre-hooks.d',
'post': '/etc/commit/post-hooks.d'
}
airbag_noteworthy_size = 20
SSH_DSA_DEPRECATION_WARNING: str = \
'Support for SSH-DSA keys is deprecated and will be removed in VyOS 1.6. ' \
'Please update affected keys to a supported algorithm (e.g., RSA, ECDSA or ' \
'ED25519) to avoid authentication failures after the upgrade.'
diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py
index 98e5840a9..bf44dcfc7 100755
--- a/src/conf_mode/pki.py
+++ b/src/conf_mode/pki.py
@@ -1,607 +1,619 @@
#!/usr/bin/env python3
#
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from sys import argv
from sys import exit
from vyos.base import Message
from vyos.config import Config
from vyos.config import config_dict_merge
from vyos.configdep import set_dependents
from vyos.configdep import call_dependents
from vyos.configdict import node_changed
from vyos.configdiff import Diff
from vyos.configdiff import get_config_diff
from vyos.defaults import directories
from vyos.defaults import internal_ports
from vyos.defaults import systemd_services
from vyos.pki import encode_certificate
from vyos.pki import is_ca_certificate
from vyos.pki import load_certificate
from vyos.pki import load_public_key
from vyos.pki import load_openssh_public_key
from vyos.pki import load_openssh_private_key
from vyos.pki import load_private_key
from vyos.pki import load_crl
from vyos.pki import load_dh_parameters
from vyos.utils.boot import boot_configuration_complete
from vyos.utils.configfs import add_cli_node
from vyos.utils.dict import dict_search
from vyos.utils.dict import dict_search_args
from vyos.utils.dict import dict_search_recursive
+from vyos.utils.dict import dict_set_nested
from vyos.utils.file import read_file
from vyos.utils.network import check_port_availability
from vyos.utils.process import call
from vyos.utils.process import cmd
from vyos.utils.process import is_systemd_service_active
from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
from vyos import airbag
airbag.enable()
vyos_certbot_dir = directories['certbot']
vyos_ca_certificates_dir = directories['ca_certificates']
# keys to recursively search for under specified path
sync_search = [
{
'keys': ['certificate'],
'path': ['service', 'https'],
},
{
'keys': ['key'],
'path': ['service', 'ssh'],
},
{
'keys': ['certificate', 'ca_certificate'],
'path': ['interfaces', 'ethernet'],
},
{
'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'],
'path': ['interfaces', 'openvpn'],
},
{
'keys': ['ca_certificate'],
'path': ['interfaces', 'sstpc'],
},
{
'keys': ['certificate', 'ca_certificate'],
'path': ['load_balancing', 'haproxy'],
},
{
'keys': ['key'],
'path': ['protocols', 'rpki', 'cache'],
},
{
'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'],
'path': ['vpn', 'ipsec'],
},
{
'keys': ['certificate', 'ca_certificate'],
'path': ['vpn', 'openconnect'],
},
{
'keys': ['certificate', 'ca_certificate'],
'path': ['vpn', 'sstp'],
},
{
'keys': ['certificate', 'ca_certificate'],
'path': ['service', 'stunnel'],
}
]
# key from other config nodes -> key in pki['changed'] and pki
sync_translate = {
'certificate': 'certificate',
'ca_certificate': 'ca',
'dh_params': 'dh',
'local_key': 'key_pair',
'remote_key': 'key_pair',
'shared_secret_key': 'openvpn',
'auth_key': 'openvpn',
'crypt_key': 'openvpn',
'key': 'openssh',
}
def certbot_delete(certificate):
if not boot_configuration_complete():
return
if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'):
cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}')
-def certbot_request(name: str, config: dict, dry_run: bool=True):
+def certbot_request(name: str, config: dict, dry_run: bool=True) -> None:
# We do not call certbot when booting the system - there is no need to do so and
# request new certificates during boot/image upgrade as the certbot configuration
# is stored persistent under /config - thus we do not open the door to transient
# errors
if not boot_configuration_complete():
- return
+ return None
domains = '--domains ' + ' --domains '.join(config['domain_name'])
tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\
f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\
f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\
f'{domains}'
listen_address = None
if 'listen_address' in config:
listen_address = config['listen_address']
# When ACME is used behind a reverse proxy, we always bind to localhost
# whatever the CLI listen-address is configured for.
if ('used_by' in config and 'haproxy' in config['used_by'] and
is_systemd_service_running(systemd_services['haproxy']) and
not check_port_availability(listen_address, 80)):
tmp += f' --http-01-address 127.0.0.1 --http-01-port {internal_ports["certbot_haproxy"]}'
elif listen_address:
tmp += f' --http-01-address {listen_address}'
# verify() does not need to actually request a cert but only test for plausability
if dry_run:
tmp += ' --dry-run'
- cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!')
+ cmd(tmp, raising=ConfigError, message=f'Certbot request failed for "{name}"!')
+ return None
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
base = ['pki']
pki = conf.get_config_dict(base, key_mangling=('-', '_'),
get_first_key=True,
no_tag_node_value_mangle=True)
if len(argv) > 1 and argv[1] == 'certbot_renew':
pki['certbot_renew'] = {}
# Walk through the list of sync_translate mapping and build a list
# which is later used to check if the node was changed in the CLI config
changed_keys = []
for value in sync_translate.values():
if value not in changed_keys:
changed_keys.append(value)
# Check for changes to said given keys in the CLI config
for key in changed_keys:
tmp = node_changed(conf, base + [key], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
- if 'changed' not in pki:
- pki.update({'changed':{}})
- pki['changed'].update({key.replace('-', '_') : tmp})
+ if tmp:
+ dict_set_nested(f'changed.{key.replace("-", "_")}', tmp, pki)
# We only merge on the defaults if there is a configuration at all
if conf.exists(base):
# We have gathered the dict representation of the CLI, but there are default
# options which we need to update into the dictionary retrived.
default_values = conf.get_config_defaults(**pki.kwargs, recursive=True)
# remove ACME default configuration if unused by CLI
if 'certificate' in pki:
for name, cert_config in pki['certificate'].items():
if 'acme' not in cert_config:
# Remove ACME default values
del default_values['certificate'][name]['acme']
# merge CLI and default dictionary
pki = config_dict_merge(default_values, pki)
# Certbot triggered an external renew of the certificates.
# Mark all ACME based certificates as "changed" to trigger
# update of dependent services
if 'certificate' in pki and 'certbot_renew' in pki:
renew = []
for name, cert_config in pki['certificate'].items():
if 'acme' in cert_config:
renew.append(name)
- # If triggered externally by certbot, certificate key is not present in changed
- if 'changed' not in pki: pki.update({'changed':{}})
- pki['changed'].update({'certificate' : renew})
+ if renew:
+ # Get the current list of changed certificates
+ tmp = pki.get('changed', {}).get('certificate', [])
+ # and extend it with the list of ACME based certificates
+ tmp += renew
+ # remove any duplicates if necessary
+ tmp = set(tmp)
+ dict_set_nested('changed.certificate', tmp, pki)
# We need to get the entire system configuration to verify that we are not
# deleting a certificate that is still referenced somewhere!
pki['system'] = conf.get_config_dict([], key_mangling=('-', '_'),
get_first_key=True,
no_tag_node_value_mangle=True)
D = get_config_diff(conf)
for search in sync_search:
for key in search['keys']:
changed_key = sync_translate[key]
if 'changed' not in pki or changed_key not in pki['changed']:
continue
for item_name in pki['changed'][changed_key]:
node_present = False
if changed_key == 'openvpn':
node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name)
else:
node_present = dict_search_args(pki, changed_key, item_name)
if node_present:
search_dict = dict_search_args(pki['system'], *search['path'])
if not search_dict:
continue
for found_name, found_path in dict_search_recursive(search_dict, key):
if isinstance(found_name, list) and item_name not in found_name:
continue
if isinstance(found_name, str) and found_name != item_name:
continue
path = search['path']
- path_str = ' '.join(path + found_path).replace('_','-')
- Message(f'Updating configuration: "{path_str} {item_name}"')
+ # Only enable this for debug purposes - otherwise we will always
+ # print this message for ACME certificates during renew tests -
+ # even if they are not due for renew!
+ # path_str = ' '.join(path + found_path).replace('_','-')
+ # print(f'Updating configuration: "{path_str} {item_name}"')
if path[0] == 'interfaces':
ifname = found_path[0]
if not D.node_changed_presence(path + [ifname]):
set_dependents(path[1], conf, ifname)
else:
if not D.node_changed_presence(path):
set_dependents(path[1], conf)
# Check PKI certificates if they are auto-generated by ACME. If they are,
# traverse the current configuration and determine the service where the
# certificate is used by.
# Required to check if we might need to run certbot behing a reverse proxy.
if 'certificate' in pki:
for name, cert_config in pki['certificate'].items():
if 'acme' not in cert_config:
continue
if not dict_search('system.load_balancing.haproxy', pki):
continue
# Determine which service depends on ACME issued certificates
+ # We only need to add services blocking the default certbot ports
+ # 80 and 443. For instance there won't be a conflict with strongSwan
+ # as it runs on different ports.
used_by = []
# We start with HAProxy
for cert_list, _ in dict_search_recursive(
pki['system']['load_balancing']['haproxy'], 'certificate'):
if name in cert_list:
used_by.append('haproxy')
# Check if OpenConnect consumes an ACME certificate
tmp = dict_search('system.vpn.openconnect.ssl.certificate', pki)
if tmp and tmp in cert_list:
used_by.append('openconnect')
if used_by:
pki['certificate'][name]['acme'].update({'used_by': used_by})
return pki
def is_valid_certificate(raw_data):
# 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_openssh_public_key(raw_data, type):
# If it loads correctly we're good, or return False
return load_openssh_public_key(raw_data, type)
def is_valid_openssh_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_openssh_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 'acme' in cert_conf:
if 'domain_name' not in cert_conf['acme']:
raise ConfigError(f'At least one domain-name is required to request '\
f'certificate for "{name}" via ACME!')
if 'email' not in cert_conf['acme']:
raise ConfigError(f'An email address is required to request '\
f'certificate for "{name}" via ACME!')
listen_address = None
if 'listen_address' in cert_conf['acme']:
listen_address = cert_conf['acme']['listen_address']
if 'used_by' not in cert_conf['acme']:
# A call to check_port_availability() will always fail during system
# boot when listen_address is set and the address is not yet assigned
# to an interface. This happens b/c PKI subsystem is called prior
# to any inteface - e.g. ethernet - and thus the OS will always
# be unable to bind() a socket() to a non existing IP address.
if boot_configuration_complete() and not check_port_availability(listen_address, 80):
raise ConfigError('Port 80 is already in use and not available '\
f'to provide ACME challenge for "{name}"!')
+ # Only run the ACME command if something on this entity changed,
+ # as this is time intensive
if 'certbot_renew' not in pki:
- # Only run the ACME command if something on this entity changed,
- # as this is time intensive
tmp = dict_search('changed.certificate', pki)
if tmp != None and name in tmp:
certbot_request(name, cert_conf['acme'])
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 'openssh' in pki:
for name, key_conf in pki['openssh'].items():
if 'public' in key_conf and 'key' in key_conf['public']:
if 'type' not in key_conf['public']:
raise ConfigError(f'Must define OpenSSH public key type for "{name}"')
if not is_valid_openssh_public_key(key_conf['public']['key'], key_conf['public']['type']):
raise ConfigError(f'Invalid OpenSSH public key "{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_openssh_private_key(private['key'], protected):
raise ConfigError(f'Invalid OpenSSH private key "{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('Invalid default country value. '\
'Value must be 2 alpha characters.')
if 'changed' in pki:
# if the list is getting longer, we can move to a dict() and also embed the
# search key as value from line 173 or 176
for search in sync_search:
for key in search['keys']:
changed_key = sync_translate[key]
if changed_key not in pki['changed']:
continue
for item_name in pki['changed'][changed_key]:
node_present = False
if changed_key == 'openvpn':
node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name)
else:
node_present = dict_search_args(pki, changed_key, item_name)
# If the node is still present, we can skip the check
# as we are not deleting it
if node_present:
continue
search_dict = dict_search_args(pki['system'], *search['path'])
if not search_dict:
continue
for found_name, found_path in dict_search_recursive(search_dict, key):
# Check if the name matches either by string compare, or beeing
# part of a list
if ((isinstance(found_name, str) and found_name == item_name) or
(isinstance(found_name, list) and item_name in found_name)):
# We do not support _ in CLI paths - this is only a convenience
# as we mangle all - to _, now it's time to reverse this!
path_str = ' '.join(search['path'] + found_path).replace('_','-')
object = changed_key.replace('_','-')
tmp = f'Embedded PKI {object} with name "{item_name}" is still '\
f'in use by CLI path "{path_str}"'
raise ConfigError(tmp)
return None
def cleanup_system_ca():
if not os.path.exists(vyos_ca_certificates_dir):
os.mkdir(vyos_ca_certificates_dir)
else:
for filename in os.listdir(vyos_ca_certificates_dir):
full_path = os.path.join(vyos_ca_certificates_dir, filename)
if os.path.isfile(full_path):
os.unlink(full_path)
def generate(pki):
if not pki:
cleanup_system_ca()
return None
# Create or cleanup CA install directory
if 'changed' in pki and 'ca' in pki['changed']:
cleanup_system_ca()
if 'ca' in pki:
for ca, ca_conf in pki['ca'].items():
if 'system_install' in ca_conf:
ca_obj = load_certificate(ca_conf['certificate'])
ca_path = os.path.join(vyos_ca_certificates_dir, f'{ca}.crt')
with open(ca_path, 'w') as f:
f.write(encode_certificate(ca_obj))
# Certbot renewal only needs to re-trigger the services to load up the
# new PEM file
if 'certbot_renew' in pki:
return None
certbot_list = []
certbot_list_on_disk = []
if os.path.exists(f'{vyos_certbot_dir}/live'):
certbot_list_on_disk = [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()]
if 'certificate' in pki:
changed_certificates = dict_search('changed.certificate', pki)
for name, cert_conf in pki['certificate'].items():
if 'acme' in cert_conf:
certbot_list.append(name)
# There is no ACME/certbot managed certificate presend on the
# system, generate it
if name not in certbot_list_on_disk:
certbot_request(name, cert_conf['acme'], dry_run=False)
# Now that the certificate was properly generated we have
# the PEM files on disk. We need to add the certificate to
# certbot_list_on_disk to automatically import the CA chain
certbot_list_on_disk.append(name)
# We alredy had an ACME managed certificate on the system, but
# something changed in the configuration
elif changed_certificates != None and name in changed_certificates:
# Delete old ACME certificate first
if name in certbot_list_on_disk:
certbot_delete(name)
# Request new certificate via certbot
certbot_request(name, cert_conf['acme'], dry_run=False)
# Cleanup certbot configuration and certificates if no longer in use by CLI
# Get foldernames under vyos_certbot_dir which each represent a certbot cert
if os.path.exists(f'{vyos_certbot_dir}/live'):
for cert in certbot_list_on_disk:
# ACME certificate is no longer in use by CLI remove it
if cert not in certbot_list:
certbot_delete(cert)
continue
# ACME not enabled for individual certificate - bail out early
if 'acme' not in pki['certificate'][cert]:
continue
# Read in ACME certificate chain information
tmp = read_file(f'{vyos_certbot_dir}/live/{cert}/chain.pem')
tmp = load_certificate(tmp, wrap_tags=False)
cert_chain_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1])
# Check if CA chain certificate is already present on CLI to avoid adding
# a duplicate. This only checks for manual added CA certificates and not
# auto added ones with the AUTOCHAIN_ prefix
autochain_prefix = 'AUTOCHAIN_'
ca_cert_present = False
if 'ca' in pki:
for ca_base64, cli_path in dict_search_recursive(pki['ca'], 'certificate'):
# Ignore automatic added CA certificates
if any(item.startswith(autochain_prefix) for item in cli_path):
continue
if cert_chain_base64 == ca_base64:
ca_cert_present = True
if not ca_cert_present:
tmp = dict_search_args(pki, 'ca', f'{autochain_prefix}{cert}', 'certificate')
if not bool(tmp) or tmp != cert_chain_base64:
Message(f'Add/replace automatically imported CA certificate for "{cert}" ...')
add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64)
return None
def apply(pki):
systemd_certbot_name = 'certbot.timer'
if not pki:
call(f'systemctl stop {systemd_certbot_name}')
call('update-ca-certificates')
return None
has_certbot = False
if 'certificate' in pki:
for name, cert_conf in pki['certificate'].items():
if 'acme' in cert_conf:
has_certbot = True
break
if not has_certbot:
call(f'systemctl stop {systemd_certbot_name}')
elif has_certbot and not is_systemd_service_active(systemd_certbot_name):
call(f'systemctl restart {systemd_certbot_name}')
if 'changed' in pki:
call_dependents()
# Rebuild ca-certificates bundle
if 'ca' in pki['changed']:
call('update-ca-certificates')
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_openconnect.py b/src/conf_mode/vpn_openconnect.py
index 78cdaa179..68cd363f0 100755
--- a/src/conf_mode/vpn_openconnect.py
+++ b/src/conf_mode/vpn_openconnect.py
@@ -1,418 +1,420 @@
#!/usr/bin/env python3
#
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from sys import exit
from vyos.base import Warning
from vyos.config import Config
from vyos.configverify import verify_pki_certificate
from vyos.configverify import verify_pki_ca_certificate
+from vyos.defaults import systemd_services
from vyos.pki import find_chain
from vyos.pki import encode_certificate
from vyos.pki import load_certificate
from vyos.pki import wrap_private_key
from vyos.template import render
from vyos.utils.dict import dict_search
from vyos.utils.file import write_file
from vyos.utils.network import check_port_availability
from vyos.utils.network import is_listen_port_bind_service
from vyos.utils.process import call
from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
from passlib.hash import sha512_crypt
from time import sleep
from vyos import airbag
airbag.enable()
cfg_dir = '/run/ocserv'
ocserv_conf = cfg_dir + '/ocserv.conf'
ocserv_passwd = cfg_dir + '/ocpasswd'
ocserv_otp_usr = cfg_dir + '/users.oath'
radius_cfg = cfg_dir + '/radiusclient.conf'
radius_servers = cfg_dir + '/radius_servers'
# Generate hash from user cleartext password
def get_hash(password):
return sha512_crypt.hash(password)
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
base = ['vpn', 'openconnect']
if not conf.exists(base):
return None
ocserv = conf.get_config_dict(
base,
key_mangling=('-', '_'),
get_first_key=True,
with_recursive_defaults=True,
with_pki=True,
)
return ocserv
def verify(ocserv):
if ocserv is None:
return None
# Check if listen-ports not binded other services
# It can be only listen by 'ocserv-main'
for proto, port in ocserv.get('listen_ports').items():
if check_port_availability(
ocserv['listen_address'], int(port), proto
) is not True and not is_listen_port_bind_service(int(port), 'ocserv-main'):
raise ConfigError(f'"{proto}" port "{port}" is used by another service')
# Check accounting
if 'accounting' in ocserv:
if 'mode' in ocserv['accounting'] and 'radius' in ocserv['accounting']['mode']:
if not ocserv['accounting']['radius']['server']:
raise ConfigError(
'OpenConnect accounting mode radius requires at least one RADIUS server'
)
if 'authentication' not in ocserv or 'mode' not in ocserv['authentication']:
raise ConfigError(
'Accounting depends on OpenConnect authentication configuration'
)
elif 'radius' not in ocserv['authentication']['mode']:
raise ConfigError(
'RADIUS accounting must be used with RADIUS authentication'
)
# Check authentication
if 'authentication' in ocserv:
if 'mode' in ocserv['authentication']:
if (
'local' in ocserv['authentication']['mode']
and 'radius' in ocserv['authentication']['mode']
):
raise ConfigError(
'OpenConnect authentication modes are mutually-exclusive, remove either local or radius from your configuration'
)
if 'radius' in ocserv['authentication']['mode']:
if 'server' not in ocserv['authentication']['radius']:
raise ConfigError(
'OpenConnect authentication mode radius requires at least one RADIUS server'
)
if 'local' in ocserv['authentication']['mode']:
if not ocserv.get('authentication', {}).get('local_users'):
raise ConfigError(
'OpenConnect mode local required at least one user'
)
if not ocserv['authentication']['local_users']['username']:
raise ConfigError(
'OpenConnect mode local required at least one user'
)
else:
# For OTP mode: verify that each local user has an OTP key
if 'otp' in ocserv['authentication']['mode']['local']:
users_wo_key = []
for user, user_config in ocserv['authentication'][
'local_users'
]['username'].items():
# User has no OTP key defined
if dict_search('otp.key', user_config) is None:
users_wo_key.append(user)
if users_wo_key:
raise ConfigError(
f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}'
)
# For password (and default) mode: verify that each local user has password
if (
'password' in ocserv['authentication']['mode']['local']
or 'otp' not in ocserv['authentication']['mode']['local']
):
users_wo_pswd = []
for user in ocserv['authentication']['local_users']['username']:
if (
'password'
not in ocserv['authentication']['local_users'][
'username'
][user]
):
users_wo_pswd.append(user)
if users_wo_pswd:
raise ConfigError(
f'password required for users:\n{users_wo_pswd}'
)
# Validate that if identity-based-config is configured all child config nodes are set
if 'identity_based_config' in ocserv['authentication']:
if 'disabled' not in ocserv['authentication']['identity_based_config']:
Warning(
'Identity based configuration files is a 3rd party addition. Use at your own risk, this might break the ocserv daemon!'
)
if 'mode' not in ocserv['authentication']['identity_based_config']:
raise ConfigError(
'OpenConnect radius identity-based-config enabled but mode not selected'
)
elif (
'group'
in ocserv['authentication']['identity_based_config']['mode']
and 'radius' not in ocserv['authentication']['mode']
):
raise ConfigError(
'OpenConnect config-per-group must be used with radius authentication'
)
if (
'directory'
not in ocserv['authentication']['identity_based_config']
):
raise ConfigError(
'OpenConnect identity-based-config enabled but directory not set'
)
if (
'default_config'
not in ocserv['authentication']['identity_based_config']
):
raise ConfigError(
'OpenConnect identity-based-config enabled but default-config not set'
)
else:
raise ConfigError('OpenConnect authentication mode required')
else:
raise ConfigError('OpenConnect authentication credentials required')
# Check ssl
if 'ssl' not in ocserv:
raise ConfigError('SSL missing on OpenConnect config!')
if 'certificate' not in ocserv['ssl']:
raise ConfigError('SSL certificate missing on OpenConnect config!')
verify_pki_certificate(ocserv, ocserv['ssl']['certificate'])
if 'ca_certificate' in ocserv['ssl']:
for ca_cert in ocserv['ssl']['ca_certificate']:
verify_pki_ca_certificate(ocserv, ca_cert)
# Check network settings
if 'network_settings' in ocserv:
if 'push_route' in ocserv['network_settings']:
# Replace default route
if '0.0.0.0/0' in ocserv['network_settings']['push_route']:
ocserv['network_settings']['push_route'].remove('0.0.0.0/0')
ocserv['network_settings']['push_route'].append('default')
else:
ocserv['network_settings']['push_route'] = ['default']
else:
raise ConfigError('OpenConnect network settings required!')
def generate(ocserv):
if not ocserv:
return None
if 'radius' in ocserv['authentication']['mode']:
if dict_search(ocserv, 'accounting.mode.radius'):
# Render radius client configuration
render(radius_cfg, 'ocserv/radius_conf.j2', ocserv)
merged_servers = (
ocserv['accounting']['radius']['server']
| ocserv['authentication']['radius']['server']
)
# Render radius servers
# Merge the accounting and authentication servers into a single dictionary
render(
radius_servers, 'ocserv/radius_servers.j2', {'server': merged_servers}
)
else:
# Render radius client configuration
render(radius_cfg, 'ocserv/radius_conf.j2', ocserv)
# Render radius servers
render(
radius_servers,
'ocserv/radius_servers.j2',
ocserv['authentication']['radius'],
)
elif 'local' in ocserv['authentication']['mode']:
# if mode "OTP", generate OTP users file parameters
if 'otp' in ocserv['authentication']['mode']['local']:
if 'local_users' in ocserv['authentication']:
for user in ocserv['authentication']['local_users']['username']:
# OTP token type from CLI parameters:
otp_interval = str(
ocserv['authentication']['local_users']['username'][user][
'otp'
].get('interval')
)
token_type = ocserv['authentication']['local_users']['username'][
user
]['otp'].get('token_type')
otp_length = str(
ocserv['authentication']['local_users']['username'][user][
'otp'
].get('otp_length')
)
if token_type == 'hotp-time':
otp_type = 'HOTP/T' + otp_interval
elif token_type == 'hotp-event':
otp_type = 'HOTP/E'
else:
otp_type = 'HOTP/T' + otp_interval
ocserv['authentication']['local_users']['username'][user]['otp'][
'token_tmpl'
] = otp_type + '/' + otp_length
# if there is a password, generate hash
if (
'password' in ocserv['authentication']['mode']['local']
or 'otp' not in ocserv['authentication']['mode']['local']
):
if 'local_users' in ocserv['authentication']:
for user in ocserv['authentication']['local_users']['username']:
ocserv['authentication']['local_users']['username'][user][
'hash'
] = get_hash(
ocserv['authentication']['local_users']['username'][user][
'password'
]
)
if 'password-otp' in ocserv['authentication']['mode']['local']:
# Render local users ocpasswd
render(
ocserv_passwd,
'ocserv/ocserv_passwd.j2',
ocserv['authentication']['local_users'],
)
# Render local users OTP keys
render(
ocserv_otp_usr,
'ocserv/ocserv_otp_usr.j2',
ocserv['authentication']['local_users'],
)
elif 'password' in ocserv['authentication']['mode']['local']:
# Render local users ocpasswd
render(
ocserv_passwd,
'ocserv/ocserv_passwd.j2',
ocserv['authentication']['local_users'],
)
elif 'otp' in ocserv['authentication']['mode']['local']:
# Render local users OTP keys
render(
ocserv_otp_usr,
'ocserv/ocserv_otp_usr.j2',
ocserv['authentication']['local_users'],
)
else:
# Render local users ocpasswd
render(
ocserv_passwd,
'ocserv/ocserv_passwd.j2',
ocserv['authentication']['local_users'],
)
else:
if 'local_users' in ocserv['authentication']:
for user in ocserv['authentication']['local_users']['username']:
ocserv['authentication']['local_users']['username'][user]['hash'] = (
get_hash(
ocserv['authentication']['local_users']['username'][user][
'password'
]
)
)
# Render local users
render(
ocserv_passwd,
'ocserv/ocserv_passwd.j2',
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')
if 'certificate' in ocserv['ssl']:
cert_name = ocserv['ssl']['certificate']
pki_cert = ocserv['pki']['certificate'][cert_name]
loaded_pki_cert = load_certificate(pki_cert['certificate'])
loaded_ca_certs = (
{
load_certificate(c['certificate'])
for c in ocserv['pki']['ca'].values()
}
if 'ca' in ocserv['pki']
else {}
)
cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
write_file(
cert_file_path,
'\n'.join(encode_certificate(c) for c in cert_full_chain),
)
if 'private' in pki_cert and 'key' in pki_cert['private']:
write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))
if 'ca_certificate' in ocserv['ssl']:
ca_cert_file_path = os.path.join(cfg_dir, 'ca.pem')
ca_chains = []
for ca_name in ocserv['ssl']['ca_certificate']:
pki_ca_cert = ocserv['pki']['ca'][ca_name]
loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
ca_chains.append(
'\n'.join(encode_certificate(c) for c in ca_full_chain)
)
write_file(ca_cert_file_path, '\n'.join(ca_chains))
# Render config
render(ocserv_conf, 'ocserv/ocserv_config.j2', ocserv)
def apply(ocserv):
+ service_name = systemd_services['openconnect']
if not ocserv:
- call('systemctl stop ocserv.service')
+ call(f'systemctl stop {service_name}')
for file in [ocserv_conf, ocserv_passwd, ocserv_otp_usr]:
if os.path.exists(file):
os.unlink(file)
else:
- call('systemctl reload-or-restart ocserv.service')
+ call(f'systemctl reload-or-restart {service_name}')
counter = 0
while True:
# exit early when service runs
- if is_systemd_service_running('ocserv.service'):
+ if is_systemd_service_running(service_name):
break
sleep(0.250)
if counter > 5:
raise ConfigError(
'OpenConnect failed to start, check the logs for details'
)
break
counter += 1
if __name__ == '__main__':
try:
c = get_config()
verify(c)
generate(c)
apply(c)
except ConfigError as e:
print(e)
exit(1)
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 26, 2:15 PM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
4285371
Default Alt Text
(48 KB)
Attached To
Mode
rVYOSONEX vyos-1x
Attached
Detach File
Event Timeline
Log In to Comment