Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/page/local_network/cards/usp_lan_info_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ class UspLanInfoCard extends ConsumerWidget {
copyable: true,
)
else if (info.ipv6Enabled)
InfoGridItem(label: 'IPv6', value: 'Enabled'),
// Issue #1129: IPv6 is enabled but no meaningful (global/ULA)
// address is available (e.g. the interface holds only a
// link-local fe80:: address). Render '-' consistent with how
// the DNS field renders when unavailable, not the link-local
// address and not a bare 'Enabled' label.
InfoGridItem(label: 'IPv6', value: '-'),
],
),
],
Expand Down
20 changes: 19 additions & 1 deletion lib/page/local_network/services/usp_lan_data_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,29 @@ class UspLanDataService {
final instances = resp.getInstances('Device.IP.Interface.1.IPv6Address.');
return instances
.map((i) => i.getString('IPAddress'))
.where((ip) => ip.isNotEmpty)
.where((ip) => ip.isNotEmpty && !_isLinkLocalIpv6(ip))
.toList();
} catch (e) {
logger.w('[USP][LanData]: IPv6 addresses fetch failed: $e');
return const <String>[];
}
}

/// Returns true if [ip] is an IPv6 link-local address (`fe80::/10`).
///
/// Issue #1129: when the LAN interface holds only a link-local address
/// (scope link, e.g. `fe80::7612:13ff:fe21:5394`) and no global/ULA prefix,
/// the widget must render empty rather than the link-local address, since a
/// link-local address is only valid on a single link and is not a meaningful
/// LAN IPv6 address. The `fe80::/10` block covers any address whose first
/// hextet, masked with `0xffc0`, equals `0xfe80` (i.e. `fe80`–`febf`).
static bool _isLinkLocalIpv6(String ip) {
// Drop any zone index (e.g. "fe80::1%eth0") before parsing.
final addr = ip.split('%').first.trim();
final firstHextet = addr.split(':').first;
if (firstHextet.isEmpty) return false;
final value = int.tryParse(firstHextet, radix: 16);
if (value == null) return false;
return (value & 0xffc0) == 0xfe80;
}
}
78 changes: 77 additions & 1 deletion test/page/local_network/providers/lan_data_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ void main() {
};

/// IPv6 response from raw usp.get().
/// Includes a link-local (fe80::) address that must be filtered out (#1129)
/// and a global address that must be kept.
final ipv6Response = <String, dynamic>{
'Device.IP.Interface.1.IPv6Enable': true,
'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fe80::1',
Expand Down Expand Up @@ -65,7 +67,81 @@ void main() {
expect(data.model.dnsServers, '8.8.8.8,8.8.4.4');
expect(data.model.hostName, 'LinksysRouter');
expect(data.model.ipv6Enabled, isTrue);
expect(data.model.ipv6Addresses, ['fe80::1', '2001:db8::1']);
// #1129: link-local fe80:: is filtered out; only the global address remains.
expect(data.model.ipv6Addresses, ['2001:db8::1']);
container.dispose();
});

test('link-local-only IPv6 yields empty addresses (#1129)', () async {
// Reproduces the reported case: br-lan holds only a scope-link fe80::
// address and no global/ULA prefix. The link-local address must NOT be
// surfaced to the widget.
when(() => mockUsp.get(any())).thenAnswer((_) async {
final paths = _.positionalArguments[0] as List;
if (paths.any((p) => p.toString().contains('IPv6Address'))) {
return <String, dynamic>{
'Device.IP.Interface.1.IPv6Enable': true,
'Device.IP.Interface.1.IPv6Address.1.IPAddress':
'fe80::7612:13ff:fe21:5394',
};
}
return lanInfoResponse;
});

final container = createContainer();
final data = await container.read(lanDataProvider.future);

expect(data.model.ipv6Enabled, isTrue);
expect(data.model.ipv6Addresses, isEmpty);
container.dispose();
});

test('link-local with zone index is filtered, global kept (#1129)',
() async {
when(() => mockUsp.get(any())).thenAnswer((_) async {
final paths = _.positionalArguments[0] as List;
if (paths.any((p) => p.toString().contains('IPv6Address'))) {
return <String, dynamic>{
'Device.IP.Interface.1.IPv6Enable': true,
'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fe80::1%eth0',
'Device.IP.Interface.1.IPv6Address.2.IPAddress': 'febf::1',
'Device.IP.Interface.1.IPv6Address.3.IPAddress': '2001:db8:abcd::5',
};
}
return lanInfoResponse;
});

final container = createContainer();
final data = await container.read(lanDataProvider.future);

// fe80::1%eth0 (zone index) and febf::1 (top of fe80::/10) are link-local;
// only the global address survives.
expect(data.model.ipv6Addresses, ['2001:db8:abcd::5']);
container.dispose();
});

test('fec0::1 is NOT link-local and must NOT be filtered (#1129)',
() async {
// fec0::1 is the first address just above the fe80::/10 range
// (link-local spans fe80::-febf::). It is a deprecated site-local
// address (RFC 3513), NOT link-local:
// 0xfec0 & 0xffc0 == 0xfec0 != 0xfe80
// The filter must keep it. This guards the upper boundary of fe80::/10.
when(() => mockUsp.get(any())).thenAnswer((_) async {
final paths = _.positionalArguments[0] as List;
if (paths.any((p) => p.toString().contains('IPv6Address'))) {
return <String, dynamic>{
'Device.IP.Interface.1.IPv6Enable': true,
'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fec0::1',
};
}
return lanInfoResponse;
});

final container = createContainer();
final data = await container.read(lanDataProvider.future);

expect(data.model.ipv6Addresses, ['fec0::1']);
container.dispose();
});

Expand Down
Loading