diff options
| author | Christian Breunig <christian@breunig.cc> | 2026-09-24 19:01:00 +0000 |
|---|---|---|
| committer | Christian Breunig <christian@breunig.cc> | 2026-09-24 19:01:00 +0000 |
| commit | 53b661d60e50edd1079a2f55e0cb3e44e76553e2 (patch) | |
| tree | 21f5420f71cf4cbaace8943ac4047761dea34414 /smoketest/scripts/cli | |
| parent | e0ca5c66796337dd992d6572019ea6f4edb475dc (diff) | |
| download | vyos-1x-53b661d60e50edd1079a2f55e0cb3e44e76553e2.tar.gz vyos-1x-53b661d60e50edd1079a2f55e0cb3e44e76553e2.zip | |
ppp: T9060: do not derive a link-local address from a synthetic MAC
A PPP link has no hardware address. RFC 5072 defines the IPv6 interface
identifier of such a link as the one negotiated via IPV6CP, and that
negotiation is also what guarantees its uniqueness - which is why duplicate
address detection is skipped for the link-local address on PPP links.
VyOS additionally derived an EUI-64 link-local address from a synthetic MAC
address and assigned it to PPPoE and SSTP client interfaces. The peer knows
nothing about that address and its uniqueness was never verified. With two
link-local addresses present, RFC 6724 source address selection falls through
to rule 8 and may pick the synthetic one, so DHCPv6 prefix delegation and
router solicitations leave from an address the BRAS never negotiated. ISPs
validating the source address silently drop them and delegation stalls.
Both interface types now carry only the address negotiated via IPV6CP. A
leftover address on an established session is removed on commit.
Diffstat (limited to 'smoketest/scripts/cli')
| -rwxr-xr-x | smoketest/scripts/cli/test_interfaces_pppoe.py | 80 |
1 files changed, 74 insertions, 6 deletions
diff --git a/smoketest/scripts/cli/test_interfaces_pppoe.py b/smoketest/scripts/cli/test_interfaces_pppoe.py index 2c240d20e..dfdd3c21f 100755 --- a/smoketest/scripts/cli/test_interfaces_pppoe.py +++ b/smoketest/scripts/cli/test_interfaces_pppoe.py @@ -24,8 +24,12 @@ from ipaddress import IPv6Network from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError +from vyos.ifconfig import PPPoEIf +from vyos.ifconfig.interface import link_local_prefix from vyos.utils.dict import dict_search_recursive +from vyos.utils.misc import wait_for from vyos.utils.network import get_interface_address +from vyos.utils.network import mac2eui64 from vyos.xml_ref import default_value config_file: str = '/etc/ppp/peers/{}' @@ -73,6 +77,19 @@ def wait_for_interface(interface: str, timeout=connect_timeout) -> bool: return False return True +def get_interface_addresses(interface, family) -> list: + """ Return the list of addresses of a given family assigned to interface """ + tmp = get_interface_address(interface) + if not tmp or 'addr_info' not in tmp: + return [] + return [addr['local'] for addr in tmp['addr_info'] + if 'family' in addr and addr['family'] == family] + +def has_global_ipv6_address(interface) -> bool: + """ Check if the interface got a non link-local IPv6 address assigned """ + return any(not IPv6Address(addr).is_link_local + for addr in get_interface_addresses(interface, 'inet6')) + # add a classmethod to setup a temporaray PPPoE server for "proper" validation class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase): @classmethod @@ -137,14 +154,20 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase): def _verify_interface_address(self, interface): # Verify that the assigned IPv4/IPv6 addresses from the BRAS (PPPoE - # server) are from the assigned pools - for address in get_interface_address(interface): - if 'family' in address and address['family'] == 'inet': + # server) are from the assigned pools - 'local' is our own address + tmp = get_interface_address(interface) + self.assertIn('addr_info', tmp) + + for addr_info in tmp['addr_info']: + if 'family' not in addr_info: + continue + + if addr_info['family'] == 'inet': # The PPPoE assigned IPv4 address must be from our pool - self.assertIn(IPv4Address(address['address']), IPv4Network(ipv4_pool)) - elif 'family' in address and address['family'] == 'inet6': + self.assertIn(IPv4Address(addr_info['local']), IPv4Network(ipv4_pool)) + elif addr_info['family'] == 'inet6': # The PPPoE assigned IPv6 address must be from our pool - ipv6 = IPv6Address(address['address']) + ipv6 = IPv6Address(addr_info['local']) if not ipv6.is_link_local: self.assertIn(ipv6, IPv6Network(ipv6_pool)) @@ -327,6 +350,51 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase): self.cli_delete(['interfaces', 'dummy', delegate_if]) + def test_pppoe_ipv6_link_local(self): + # T9060: the IPv6 interface identifier of a PPP link is negotiated + # with the peer via IPV6CP (RFC 5072). A second, EUI-64 derived + # link-local address is unknown to the BRAS, and RFC 6724 source + # address selection may pick it for e.g. DHCPv6-PD. + for interface in self._interfaces: + (user, passwd) = self.u_p_dict[interface] + + self.cli_set(base_path + [interface, 'authentication', 'username', user]) + self.cli_set(base_path + [interface, 'authentication', 'password', passwd]) + self.cli_set(base_path + [interface, 'no-peer-dns']) + self.cli_set(base_path + [interface, 'source-interface', self._source_interface]) + # Only with the "ipv6" node present pppd is told to negotiate + # IPV6CP - without it the peer config contains "noipv6" + self.cli_set(base_path + [interface, 'ipv6', 'address', 'autoconf']) + + # commit changes + self.cli_commit() + + for interface in self._interfaces: + self.assertTrue(wait_for_interface(interface), + msg=f'Interface {interface} not found after {connect_timeout} seconds!') + + # The link-local address is assigned before the router + # solicitation is sent out - once a global IPv6 address is present + # we know we are not testing too early + self.assertTrue(wait_for(has_global_ipv6_address, interface, + interval=0.250, timeout=connect_timeout), + msg=f'Interface {interface} got no global IPv6 address!') + + link_local = [addr for addr in get_interface_addresses(interface, 'inet6') + if IPv6Address(addr).is_link_local] + + # There must be exactly one link-local address - the one negotiated + # via IPV6CP + self.assertEqual(len(link_local), 1) + + # ... and it must not be the one derived from the synthetic MAC + # address of the interface + eui64 = mac2eui64(PPPoEIf(interface).get_mac(), link_local_prefix) + self.assertNotIn(eui64, link_local) + + # Validate and verify assigned IP addresses + self._verify_interface_address(interface) + def test_pppoe_options(self): # Verify access-concentrator and service-name CLI options |
