Skip to content

Commit

Permalink
ethernet: T6709: move EAPoL support to common framework
Browse files Browse the repository at this point in the history
Instead of having EAPoL (Extensible Authentication Protocol over Local Area
Network) support only available for ethernet interfaces, move this to common
ground at vyos.ifconfig.interface making it available for all sorts of
interfaces by simply including the XML portion

  #include <include/interface/eapol.xml.i>
  • Loading branch information
c-po committed Sep 14, 2024
1 parent 5df36ba commit 0ee8d5e
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 225 deletions.
17 changes: 17 additions & 0 deletions python/vyos/configverify.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,20 @@ def verify_pki_dh_parameters(config: dict, dh_name: str, min_key_size: int=0):
dh_bits = dh_numbers.p.bit_length()
if dh_bits < min_key_size:
raise ConfigError(f'Minimum DH key-size is {min_key_size} bits!')

def verify_eapol(config: dict):
"""
Common helper function used by interface implementations to perform
recurring validation of EAPoL configuration.
"""
if 'eapol' not in config:
return

if 'certificate' not in config['eapol']:
raise ConfigError('Certificate must be specified when using EAPoL!')

verify_pki_certificate(config, config['eapol']['certificate'], no_password_protected=True)

if 'ca_certificate' in config['eapol']:
for ca_cert in config['eapol']['ca_certificate']:
verify_pki_ca_certificate(config, ca_cert)
72 changes: 68 additions & 4 deletions python/vyos/ifconfig/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@
from vyos.configdict import dict_merge
from vyos.configdict import get_vlan_ids
from vyos.defaults import directories
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 is_ipv4
from vyos.template import is_ipv6
from vyos.template import render
from vyos.utils.network import mac2eui64
from vyos.utils.dict import dict_search
Expand All @@ -41,9 +47,8 @@
from vyos.utils.network import is_netns_interface
from vyos.utils.process import is_systemd_service_active
from vyos.utils.process import run
from vyos.template import is_ipv4
from vyos.template import is_ipv6
from vyos.utils.file import read_file
from vyos.utils.file import write_file
from vyos.utils.network import is_intf_addr_assigned
from vyos.utils.network import is_ipv6_link_local
from vyos.utils.assertion import assert_boolean
Expand All @@ -52,7 +57,6 @@
from vyos.utils.assertion import assert_mtu
from vyos.utils.assertion import assert_positive
from vyos.utils.assertion import assert_range

from vyos.ifconfig.control import Control
from vyos.ifconfig.vrrp import VRRP
from vyos.ifconfig.operational import Operational
Expand Down Expand Up @@ -377,6 +381,9 @@ def remove(self):
>>> i = Interface('eth0')
>>> i.remove()
"""
# Stop WPA supplicant if EAPoL was in use
if is_systemd_service_active(f'wpa_supplicant-wired@{self.ifname}'):
self._cmd(f'systemctl stop wpa_supplicant-wired@{self.ifname}')

# remove all assigned IP addresses from interface - this is a bit redundant
# as the kernel will remove all addresses on interface deletion, but we
Expand Down Expand Up @@ -1522,6 +1529,61 @@ def set_per_client_thread(self, enable):
return None
self.set_interface('per_client_thread', enable)

def set_eapol(self) -> None:
""" Take care about EAPoL supplicant daemon """

# XXX: wpa_supplicant works on the source interface
cfg_dir = '/run/wpa_supplicant'
wpa_supplicant_conf = f'{cfg_dir}/{self.ifname}.conf'
eapol_action='stop'

if 'eapol' in self.config:
# The default is a fallback to hw_id which is not present for any interface
# other then an ethernet interface. Thus we emulate hw_id by reading back the
# Kernel assigned MAC address
if 'hw_id' not in self.config:
self.config['hw_id'] = read_file(f'/sys/class/net/{self.ifname}/address')
render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', self.config)

cert_file_path = os.path.join(cfg_dir, f'{self.ifname}_cert.pem')
cert_key_path = os.path.join(cfg_dir, f'{self.ifname}_cert.key')

cert_name = self.config['eapol']['certificate']
pki_cert = self.config['pki']['certificate'][cert_name]

loaded_pki_cert = load_certificate(pki_cert['certificate'])
loaded_ca_certs = {load_certificate(c['certificate'])
for c in self.config['pki']['ca'].values()} if 'ca' in self.config['pki'] else {}

cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)

write_file(cert_file_path,
'\n'.join(encode_certificate(c) for c in cert_full_chain))
write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))

if 'ca_certificate' in self.config['eapol']:
ca_cert_file_path = os.path.join(cfg_dir, f'{self.ifname}_ca.pem')
ca_chains = []

for ca_cert_name in self.config['eapol']['ca_certificate']:
pki_ca_cert = self.config['pki']['ca'][ca_cert_name]
loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
ca_chains.append(
'\n'.join(encode_certificate(c) for c in ca_full_chain))

write_file(ca_cert_file_path, '\n'.join(ca_chains))

eapol_action='reload-or-restart'

# start/stop WPA supplicant service
self._cmd(f'systemctl {eapol_action} wpa_supplicant-wired@{self.ifname}')

if 'eapol' not in self.config:
# delete configuration on interface removal
if os.path.isfile(wpa_supplicant_conf):
os.unlink(wpa_supplicant_conf)

def update(self, config):
""" General helper function which works on a dictionary retrived by
get_config_dict(). It's main intention is to consolidate the scattered
Expand Down Expand Up @@ -1609,7 +1671,6 @@ def update(self, config):
tmp = get_interface_config(config['ifname'])
if 'master' in tmp and tmp['master'] != bridge_if:
self.set_vrf('')

else:
self.set_vrf(config.get('vrf', ''))

Expand Down Expand Up @@ -1752,6 +1813,9 @@ def update(self, config):
value = '1' if (tmp != None) else '0'
self.set_per_client_thread(value)

# enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network)
self.set_eapol()

# Enable/Disable of an interface must always be done at the end of the
# derived class to make use of the ref-counting set_admin_state()
# function. We will only enable the interface if 'up' was called as
Expand Down
159 changes: 158 additions & 1 deletion smoketest/scripts/cli/base_interfaces_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import re

from netifaces import AF_INET
from netifaces import AF_INET6
from netifaces import ifaddresses
Expand All @@ -22,6 +24,7 @@
from vyos.defaults import directories
from vyos.ifconfig import Interface
from vyos.ifconfig import Section
from vyos.pki import CERT_BEGIN
from vyos.utils.file import read_file
from vyos.utils.dict import dict_search
from vyos.utils.process import cmd
Expand All @@ -40,6 +43,79 @@
dhcp6c_base_dir = directories['dhcp6_client_dir']
dhcp6c_process_name = 'dhcp6c'

server_ca_root_cert_data = """
MIIBcTCCARagAwIBAgIUDcAf1oIQV+6WRaW7NPcSnECQ/lUwCgYIKoZIzj0EAwIw
HjEcMBoGA1UEAwwTVnlPUyBzZXJ2ZXIgcm9vdCBDQTAeFw0yMjAyMTcxOTQxMjBa
Fw0zMjAyMTUxOTQxMjBaMB4xHDAaBgNVBAMME1Z5T1Mgc2VydmVyIHJvb3QgQ0Ew
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ0y24GzKQf4aM2Ir12tI9yITOIzAUj
ZXyJeCmYI6uAnyAMqc4Q4NKyfq3nBi4XP87cs1jlC1P2BZ8MsjL5MdGWozIwMDAP
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRwC/YaieMEnjhYa7K3Flw/o0SFuzAK
BggqhkjOPQQDAgNJADBGAiEAh3qEj8vScsjAdBy5shXzXDVVOKWCPTdGrPKnu8UW
a2cCIQDlDgkzWmn5ujc5ATKz1fj+Se/aeqwh4QyoWCVTFLIxhQ==
"""

server_ca_intermediate_cert_data = """
MIIBmTCCAT+gAwIBAgIUNzrtHzLmi3QpPK57tUgCnJZhXXQwCgYIKoZIzj0EAwIw
HjEcMBoGA1UEAwwTVnlPUyBzZXJ2ZXIgcm9vdCBDQTAeFw0yMjAyMTcxOTQxMjFa
Fw0zMjAyMTUxOTQxMjFaMCYxJDAiBgNVBAMMG1Z5T1Mgc2VydmVyIGludGVybWVk
aWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEl2nJ1CzoqPV6hWII2m
eGN/uieU6wDMECTk/LgG8CCCSYb488dibUiFN/1UFsmoLIdIhkx/6MUCYh62m8U2
WNujUzBRMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMV3YwH88I5gFsFUibbQ
kMR0ECPsMB8GA1UdIwQYMBaAFHAL9hqJ4wSeOFhrsrcWXD+jRIW7MAoGCCqGSM49
BAMCA0gAMEUCIQC/ahujD9dp5pMMCd3SZddqGC9cXtOwMN0JR3e5CxP13AIgIMQm
jMYrinFoInxmX64HfshYqnUY8608nK9D2BNPOHo=
"""

client_ca_root_cert_data = """
MIIBcDCCARagAwIBAgIUZmoW2xVdwkZSvglnkCq0AHKa6zIwCgYIKoZIzj0EAwIw
HjEcMBoGA1UEAwwTVnlPUyBjbGllbnQgcm9vdCBDQTAeFw0yMjAyMTcxOTQxMjFa
Fw0zMjAyMTUxOTQxMjFaMB4xHDAaBgNVBAMME1Z5T1MgY2xpZW50IHJvb3QgQ0Ew
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATUpKXzQk2NOVKDN4VULk2yw4mOKPvn
mg947+VY7lbpfOfAUD0QRg95qZWCw899eKnXp/U4TkAVrmEKhUb6OJTFozIwMDAP
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTXu6xGWUl25X3sBtrhm3BJSICIATAK
BggqhkjOPQQDAgNIADBFAiEAnTzEwuTI9bz2Oae3LZbjP6f/f50KFJtjLZFDbQz7
DpYCIDNRHV8zBUibC+zg5PqMpQBKd/oPfNU76nEv6xkp/ijO
"""

client_ca_intermediate_cert_data = """
MIIBmDCCAT+gAwIBAgIUJEMdotgqA7wU4XXJvEzDulUAGqgwCgYIKoZIzj0EAwIw
HjEcMBoGA1UEAwwTVnlPUyBjbGllbnQgcm9vdCBDQTAeFw0yMjAyMTcxOTQxMjJa
Fw0zMjAyMTUxOTQxMjJaMCYxJDAiBgNVBAMMG1Z5T1MgY2xpZW50IGludGVybWVk
aWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGyIVIi217s9j3O+WQ2b
6R65/Z0ZjQpELxPjBRc0CA0GFCo+pI5EvwI+jNFArvTAJ5+ZdEWUJ1DQhBKDDQdI
avCjUzBRMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFOUS8oNJjChB1Rb9Blcl
ETvziHJ9MB8GA1UdIwQYMBaAFNe7rEZZSXblfewG2uGbcElIgIgBMAoGCCqGSM49
BAMCA0cAMEQCIArhaxWgRsAUbEeNHD/ULtstLHxw/P97qPUSROLQld53AiBjgiiz
9pDfISmpekZYz6bIDWRIR0cXUToZEMFNzNMrQg==
"""

client_cert_data = """
MIIBmTCCAUCgAwIBAgIUV5T77XdE/tV82Tk4Vzhp5BIFFm0wCgYIKoZIzj0EAwIw
JjEkMCIGA1UEAwwbVnlPUyBjbGllbnQgaW50ZXJtZWRpYXRlIENBMB4XDTIyMDIx
NzE5NDEyMloXDTMyMDIxNTE5NDEyMlowIjEgMB4GA1UEAwwXVnlPUyBjbGllbnQg
Y2VydGlmaWNhdGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARuyynqfc/qJj5e
KJ03oOH8X4Z8spDeAPO9WYckMM0ldPj+9kU607szFzPwjaPWzPdgyIWz3hcN8yAh
CIhytmJao1AwTjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTIFKrxZ+PqOhYSUqnl
TGCUmM7wTjAfBgNVHSMEGDAWgBTlEvKDSYwoQdUW/QZXJRE784hyfTAKBggqhkjO
PQQDAgNHADBEAiAvO8/jvz05xqmP3OXD53XhfxDLMIxzN4KPoCkFqvjlhQIgIHq2
/geVx3rAOtSps56q/jiDouN/aw01TdpmGKVAa9U=
"""

client_key_data = """
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxaxAQsJwjoOCByQE
+qSYKtKtJzbdbOnTsKNSrfgkFH6hRANCAARuyynqfc/qJj5eKJ03oOH8X4Z8spDe
APO9WYckMM0ldPj+9kU607szFzPwjaPWzPdgyIWz3hcN8yAhCIhytmJa
"""

def get_wpa_supplicant_value(interface, key):
tmp = read_file(f'/run/wpa_supplicant/{interface}.conf')
tmp = re.findall(r'\n?{}=(.*)'.format(key), tmp)
return tmp[0]

def get_certificate_count(interface, cert_type):
tmp = read_file(f'/run/wpa_supplicant/{interface}_{cert_type}.pem')
return tmp.count(CERT_BEGIN)

def is_mirrored_to(interface, mirror_if, qdisc):
"""
Ask TC if we are mirroring traffic to a discrete interface.
Expand All @@ -57,10 +133,10 @@ def is_mirrored_to(interface, mirror_if, qdisc):
if mirror_if in tmp:
ret_val = True
return ret_val

class BasicInterfaceTest:
class TestCase(VyOSUnitTestSHIM.TestCase):
_test_dhcp = False
_test_eapol = False
_test_ip = False
_test_mtu = False
_test_vlan = False
Expand Down Expand Up @@ -92,6 +168,7 @@ def setUpClass(cls):
cls._test_vlan = cli_defined(cls._base_path, 'vif')
cls._test_qinq = cli_defined(cls._base_path, 'vif-s')
cls._test_dhcp = cli_defined(cls._base_path, 'dhcp-options')
cls._test_eapol = cli_defined(cls._base_path, 'eapol')
cls._test_ip = cli_defined(cls._base_path, 'ip')
cls._test_ipv6 = cli_defined(cls._base_path, 'ipv6')
cls._test_ipv6_dhcpc6 = cli_defined(cls._base_path, 'dhcpv6-options')
Expand Down Expand Up @@ -1158,3 +1235,83 @@ def test_dhcpv6pd_manual_sla_id(self):
# as until commit() is called, nothing happens
section = Section.section(delegatee)
self.cli_delete(['interfaces', section, delegatee])

def test_eapol(self):
if not self._test_eapol:
self.skipTest('not supported')

ca_certs = {
'eapol-server-ca-root': server_ca_root_cert_data,
'eapol-server-ca-intermediate': server_ca_intermediate_cert_data,
'eapol-client-ca-root': client_ca_root_cert_data,
'eapol-client-ca-intermediate': client_ca_intermediate_cert_data,
}
cert_name = 'eapol-client'

for name, data in ca_certs.items():
self.cli_set(['pki', 'ca', name, 'certificate', data.replace('\n','')])

self.cli_set(['pki', 'certificate', cert_name, 'certificate', client_cert_data.replace('\n','')])
self.cli_set(['pki', 'certificate', cert_name, 'private', 'key', client_key_data.replace('\n','')])

for interface in self._interfaces:
path = self._base_path + [interface]
for option in self._options.get(interface, []):
self.cli_set(path + option.split())

# Enable EAPoL
self.cli_set(self._base_path + [interface, 'eapol', 'ca-certificate', 'eapol-server-ca-intermediate'])
self.cli_set(self._base_path + [interface, 'eapol', 'ca-certificate', 'eapol-client-ca-intermediate'])
self.cli_set(self._base_path + [interface, 'eapol', 'certificate', cert_name])

self.cli_commit()

# Test multiple CA chains
self.assertEqual(get_certificate_count(interface, 'ca'), 4)

for interface in self._interfaces:
self.cli_delete(self._base_path + [interface, 'eapol', 'ca-certificate', 'eapol-client-ca-intermediate'])

self.cli_commit()

# Check for running process
self.assertTrue(process_named_running('wpa_supplicant'))

# Validate interface config
for interface in self._interfaces:
tmp = get_wpa_supplicant_value(interface, 'key_mgmt')
self.assertEqual('IEEE8021X', tmp)

tmp = get_wpa_supplicant_value(interface, 'eap')
self.assertEqual('TLS', tmp)

tmp = get_wpa_supplicant_value(interface, 'eapol_flags')
self.assertEqual('0', tmp)

tmp = get_wpa_supplicant_value(interface, 'ca_cert')
self.assertEqual(f'"/run/wpa_supplicant/{interface}_ca.pem"', tmp)

tmp = get_wpa_supplicant_value(interface, 'client_cert')
self.assertEqual(f'"/run/wpa_supplicant/{interface}_cert.pem"', tmp)

tmp = get_wpa_supplicant_value(interface, 'private_key')
self.assertEqual(f'"/run/wpa_supplicant/{interface}_cert.key"', tmp)

mac = read_file(f'/sys/class/net/{interface}/address')
tmp = get_wpa_supplicant_value(interface, 'identity')
self.assertEqual(f'"{mac}"', tmp)

# Check certificate files have the full chain
self.assertEqual(get_certificate_count(interface, 'ca'), 2)
self.assertEqual(get_certificate_count(interface, 'cert'), 3)

for name in ca_certs:
self.cli_delete(['pki', 'ca', name])
self.cli_delete(['pki', 'certificate', cert_name])

# Remove EAPoL configuration
self.cli_delete(self._base_path + [interface, 'eapol'])
# Commit
self.cli_commit()
# Daemon must no longer be running
self.assertFalse(process_named_running('wpa_supplicant'))
Loading

0 comments on commit 0ee8d5e

Please sign in to comment.