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
129 changes: 129 additions & 0 deletions lib/core/utils/ipv6_address.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/// IPv6 address classification and ordering utilities.
///
/// Routers commonly expose several IPv6 addresses per interface (e.g. a
/// link-local `fe80::/10`, a Unique Local Address `fc00::/7`, and one or more
/// global unicast `2000::/3` addresses). The USP data model returns these in
/// TR-181 instance order, which is *not* preference order — the link-local
/// address is frequently instance 1. When the UI needs a single
/// "representative" address (e.g. the WAN IPv6 shown on the dashboard Network
/// Status widget), it must be the globally routable one, not the link-local.
///
/// This helper classifies addresses by their high-order bytes (mirroring the
/// scheme used by [Ipv6Rule] in `validator_rules/rules.dart`) and provides an
/// ordering that surfaces global unicast addresses first.
library;

import 'package:privacy_gui/core/utils/ipv6_ranges.dart';

/// IPv6 address scope categories, ordered by display preference
/// (lower [preference] wins).
enum Ipv6Scope {
/// Global unicast (`2000::/3`) — routable on the public internet.
global(0),

/// Unique Local Address (`fc00::/7`) — routable within a site/organization.
uniqueLocal(1),

/// Link-local (`fe80::/10`) — only valid on the local link, not routable.
linkLocal(2),

/// Anything else (loopback, unspecified, multicast, unparseable, …).
other(3);

const Ipv6Scope(this.preference);

/// Sort key — lower values are preferred for display.
final int preference;
}

/// Classifies an IPv6 [address] string into an [Ipv6Scope].
///
/// Only the first two bytes are needed to distinguish global / ULA /
/// link-local, so a full parse is avoided. Returns [Ipv6Scope.other] for
/// addresses that cannot be classified (empty, IPv4, malformed).
Ipv6Scope classifyIpv6Scope(String address) {
final bytes = _firstTwoBytes(address);
if (bytes == null) return Ipv6Scope.other;

final firstByte = bytes[0];
final secondByte = bytes[1];

// Deprecated / reserved ranges that fall inside 2000::/3 by first byte must
// be excluded before the global-unicast test, matching IPv6WithReservedRule:
// * 3FFE::/16 — 6bone deprecated testing network (RFC 3701).
// * 5F00::/12 and 6000::/3–7FFF::/3 — reserved/unallocated.
if (is6boneBytes(firstByte, secondByte) || isReservedGlobalByte(firstByte)) {
return Ipv6Scope.other;
}

// Global unicast: 2000::/3 (first byte 0x20–0x3F).
if (isGlobalUnicastByte(firstByte)) return Ipv6Scope.global;

// Link-local: fe80::/10 (first byte 0xFE, top two bits of second byte = 10).
if (isLinkLocalBytes(firstByte, secondByte)) return Ipv6Scope.linkLocal;

// Unique Local Address: fc00::/7 (first byte 0xFC or 0xFD).
if (isUniqueLocalByte(firstByte)) return Ipv6Scope.uniqueLocal;

return Ipv6Scope.other;
}

/// Whether [address] is a globally routable (global unicast) IPv6 address.
bool isGlobalUnicastIpv6(String address) =>
classifyIpv6Scope(address) == Ipv6Scope.global;

/// Returns [addresses] reordered so the most routable address comes first:
/// global unicast, then ULA, then link-local, then anything else. The relative
/// order of addresses that share a scope is preserved (stable sort), so the
/// original TR-181 instance order still acts as a tie-breaker.
List<String> preferGlobalIpv6First(Iterable<String> addresses) {
final list = addresses.toList();
// List.sort is not guaranteed stable, so decorate with the original index.
final indexed = <MapEntry<int, String>>[
for (var i = 0; i < list.length; i++) MapEntry(i, list[i]),
];
indexed.sort((a, b) {
final byScope = classifyIpv6Scope(a.value)
.preference
.compareTo(classifyIpv6Scope(b.value).preference);
if (byScope != 0) return byScope;
return a.key.compareTo(b.key); // stable tie-break on original position
});
return [for (final e in indexed) e.value];
}

/// Parses just the first two bytes of an IPv6 [address].
///
/// Handles `::` zero-compression and rejects obviously invalid input. Returns
/// `null` when the address cannot be parsed into at least one hextet.
List<int>? _firstTwoBytes(String address) {
final trimmed = address.trim();
if (trimmed.isEmpty) return null;

// Strip a zone id / scope suffix (e.g. "fe80::1%eth0") and any prefix length.
var s = trimmed.split('%').first.split('/').first;
if (s.isEmpty) return null;

// Reject anything that is not hex digits or colons (e.g. IPv4).
if (!RegExp(r'^[0-9a-fA-F:]+$').hasMatch(s)) return null;

// Only one '::' is allowed.
if (s.indexOf('::') != s.lastIndexOf('::')) return null;

// The first hextet is what determines the scope. Take the substring up to
// the first ':' (or '::'); an address beginning with '::' has a zero first
// hextet.
String firstHextet;
if (s.startsWith('::')) {
firstHextet = '0';
} else {
final colon = s.indexOf(':');
firstHextet = colon == -1 ? s : s.substring(0, colon);
if (firstHextet.isEmpty) firstHextet = '0';
}

final value = int.tryParse(firstHextet, radix: 16);
if (value == null || value < 0 || value > 0xFFFF) return null;

return [(value >> 8) & 0xFF, value & 0xFF];
}
36 changes: 36 additions & 0 deletions lib/core/utils/ipv6_ranges.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// Single source of truth for IPv6 high-order byte-range classification.
///
/// Two independent call sites classify IPv6 addresses by their first two
/// bytes and had drifted into duplicated magic constants:
/// * [classifyIpv6Scope] in `core/utils/ipv6_address.dart` (display ordering)
/// * [IPv6WithReservedRule] in `validator_rules/rules.dart` (input validation)
///
/// The predicates below are the shared definition of those ranges so the two
/// callers cannot diverge. Each takes already-parsed bytes (0–0xFF) — parsing
/// remains the caller's responsibility.
library;

/// Global unicast — `2000::/3` (first byte `0x20`–`0x3F`).
bool isGlobalUnicastByte(int firstByte) =>
firstByte >= 0x20 && firstByte <= 0x3F;

/// Link-local — `fe80::/10` (first byte `0xFE`, top two bits of the second
/// byte are `10`).
bool isLinkLocalBytes(int firstByte, int secondByte) =>
firstByte == 0xFE && (secondByte & 0xC0) == 0x80;

/// Unique Local Address — `fc00::/7` (first byte `0xFC` or `0xFD`).
bool isUniqueLocalByte(int firstByte) => firstByte == 0xFC || firstByte == 0xFD;

/// 6bone deprecated IPv6 testing network — `3FFE::/16` (RFC 3701). This falls
/// inside the `2000::/3` global-unicast range by first byte, so it must be
/// excluded explicitly before applying [isGlobalUnicastByte].
bool is6boneBytes(int firstByte, int secondByte) =>
firstByte == 0x3F && secondByte == 0xFE;

/// Reserved / unallocated space adjacent to the global-unicast range
/// (e.g. `5F00::/12`, `6000::/3`–`7FFF::/3`) — first byte `0x5F`–`0x7F`.
/// These already fall outside [isGlobalUnicastByte]; the predicate exists so
/// validators that must reject them share one definition.
bool isReservedGlobalByte(int firstByte) =>
firstByte >= 0x5F && firstByte <= 0x7F;
10 changes: 9 additions & 1 deletion lib/page/internet_settings/services/usp_wan_data_service.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:privacy_gui/core/errors/service_error.dart';
import 'package:privacy_gui/core/utils/ipv6_address.dart';
import 'package:privacy_gui/core/utils/logger.dart';
import 'package:privacy_gui/core/usp/errors/usp_error.dart';
import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart';
Expand Down Expand Up @@ -96,7 +97,14 @@ class UspWanDataService {
.where((ip) => ip.isNotEmpty)
.toList();

return (gateway: gateway, ipv6Addresses: ipv6Addresses);
// TR-181 returns IPv6 addresses in instance order, which frequently puts
// the link-local (fe80::/10) address first. The WAN widget shows a single
// representative address (ipv6Addresses.first), which must be the globally
// routable one — not the link-local. Reorder so global unicast wins.
// See linksys/PrivacyGUI#1128.
final orderedIpv6Addresses = preferGlobalIpv6First(ipv6Addresses);

return (gateway: gateway, ipv6Addresses: orderedIpv6Addresses);
} catch (e) {
logger.w('[USP][WanData]: Gateway/IPv6 fetch failed: $e');
return (gateway: '', ipv6Addresses: const <String>[]);
Expand Down
11 changes: 6 additions & 5 deletions lib/validator_rules/rules.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';

import 'package:privacy_gui/core/utils/ipv6_ranges.dart';
import 'package:privacy_gui/util/network_utils.dart';

abstract class ValidationRule {
Expand Down Expand Up @@ -259,21 +260,21 @@ class IPv6WithReservedRule extends ValidationRule {
}

// 6b. Check for 3ffe::/16 (6bone - deprecated IPv6 testing network).
if (rawAddress[0] == 0x3F && rawAddress[1] == 0xFE) {
if (is6boneBytes(rawAddress[0], rawAddress[1])) {
return false;
}

// 6c. Check for other reserved ranges within 2000::/3 (e.g., 5F00::/12, 6000::/3 to 7FFF::/3).
if (rawAddress[0] >= 0x5F && rawAddress[0] <= 0x7F) {
if (isReservedGlobalByte(rawAddress[0])) {
return false;
}

// --- Rule: Must be a unicast address usable for port service ---
// Allowed: Global Unicast (2000::/3), Link-local (fe80::/10), ULA (fc00::/7)
final firstByte = rawAddress[0];
final isGlobalUnicast = firstByte >= 0x20 && firstByte <= 0x3F;
final isLinkLocal = firstByte == 0xFE && (rawAddress[1] & 0xC0) == 0x80;
final isULA = firstByte == 0xFC || firstByte == 0xFD;
final isGlobalUnicast = isGlobalUnicastByte(firstByte);
final isLinkLocal = isLinkLocalBytes(firstByte, rawAddress[1]);
final isULA = isUniqueLocalByte(firstByte);

if (!isGlobalUnicast && !isLinkLocal && !isULA) {
return false;
Expand Down
110 changes: 110 additions & 0 deletions test/core/utils/ipv6_address_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:privacy_gui/core/utils/ipv6_address.dart';

void main() {
group('classifyIpv6Scope', () {
test('link-local fe80::/10 → linkLocal', () {
expect(
classifyIpv6Scope('fe80::7612:13ff:fe21:5394'), Ipv6Scope.linkLocal);
expect(classifyIpv6Scope('fe80:0000:0000:0000:139f:b9c2:6598:5d1e'),
Ipv6Scope.linkLocal);
// febf is the top of the fe80::/10 range.
expect(classifyIpv6Scope('febf::1'), Ipv6Scope.linkLocal);
});

test('global unicast 2000::/3 → global', () {
expect(classifyIpv6Scope('2401:e180:8801:d79d:7612:13ff:fe21:5394'),
Ipv6Scope.global);
expect(classifyIpv6Scope('2401:e180:8831:505f::1'), Ipv6Scope.global);
expect(classifyIpv6Scope('2001:db8::1'), Ipv6Scope.global);
// 3fff is the top of the 2000::/3 range.
expect(classifyIpv6Scope('3fff::1'), Ipv6Scope.global);
});

test('unique local fc00::/7 → uniqueLocal', () {
expect(classifyIpv6Scope('fc00::1'), Ipv6Scope.uniqueLocal);
expect(classifyIpv6Scope('fd12:3456::1'), Ipv6Scope.uniqueLocal);
});

test('non-classifiable / malformed → other', () {
expect(classifyIpv6Scope(''), Ipv6Scope.other);
expect(classifyIpv6Scope(' '), Ipv6Scope.other);
expect(classifyIpv6Scope('192.168.1.1'), Ipv6Scope.other);
expect(classifyIpv6Scope('::1'), Ipv6Scope.other); // loopback
expect(classifyIpv6Scope('ff02::1'), Ipv6Scope.other); // multicast
expect(classifyIpv6Scope('not-an-ip'), Ipv6Scope.other);
});

test('deprecated / reserved ranges inside 2000::/3 → other (W-1)', () {
// 3FFE::/16 — 6bone deprecated testing network (RFC 3701). Falls inside
// 2000::/3 by first byte but must NOT be surfaced as global unicast,
// matching IPv6WithReservedRule in validator_rules/rules.dart.
expect(classifyIpv6Scope('3ffe::1'), Ipv6Scope.other);
// 5F00::/12 and 6000::/3–7FFF::/3 reserved/unallocated.
expect(classifyIpv6Scope('5f00::1'), Ipv6Scope.other);
expect(classifyIpv6Scope('7000::1'), Ipv6Scope.other);
// 3fff (not 6bone) remains a valid global unicast — top of 2000::/3.
expect(classifyIpv6Scope('3fff::1'), Ipv6Scope.global);
});

test('handles zone id and prefix length suffixes', () {
expect(classifyIpv6Scope('fe80::1%eth0'), Ipv6Scope.linkLocal);
expect(classifyIpv6Scope('2401:e180::1/64'), Ipv6Scope.global);
});
});

group('isGlobalUnicastIpv6', () {
test('true only for global unicast', () {
expect(isGlobalUnicastIpv6('2401:e180:8801:d79d::1'), isTrue);
expect(isGlobalUnicastIpv6('fe80::1'), isFalse);
expect(isGlobalUnicastIpv6('fc00::1'), isFalse);
expect(isGlobalUnicastIpv6(''), isFalse);
});
});

group('preferGlobalIpv6First', () {
test('surfaces global unicast ahead of link-local (issue #1128 case)', () {
// Exact ordering from the #1128 diagnostic log
// (Device.IP.Interface.2.IPv6Address.1..4).
final input = [
'fe80::7612:13ff:fe21:5394', // instance 1 — link-local (the bug)
'2401:e180:8831:505f::1', // instance 2 — global
'2401:e180:8831:505f:7612:13ff:fe21:5394', // instance 3 — global
'2401:e180:8801:d79d:7612:13ff:fe21:5394', // instance 4 — global (WAN)
];

final result = preferGlobalIpv6First(input);

// First address must now be a global unicast, not the link-local.
expect(isGlobalUnicastIpv6(result.first), isTrue);
expect(result.first, '2401:e180:8831:505f::1');
// Link-local sinks to the end.
expect(result.last, 'fe80::7612:13ff:fe21:5394');
});

test('is stable within a scope (preserves instance order)', () {
final input = [
'2401:e180:8831:505f::1',
'2401:e180:8831:505f:7612:13ff:fe21:5394',
'2401:e180:8801:d79d:7612:13ff:fe21:5394',
];
// All global → order unchanged.
expect(preferGlobalIpv6First(input), input);
});

test('orders global > ULA > link-local > other', () {
final input = [
'fe80::1', // link-local
'ff02::1', // other (multicast)
'fc00::1', // ULA
'2001:db8::1', // global
];
expect(preferGlobalIpv6First(input),
['2001:db8::1', 'fc00::1', 'fe80::1', 'ff02::1']);
});

test('empty input returns empty', () {
expect(preferGlobalIpv6First(const []), isEmpty);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ void main() {
expect(result.ipv6Addresses, contains('2001:db8::1'));
expect(result.ipv6Addresses, contains('2001:db8::2'));
});

test('global IPv6 surfaces before link-local (issue #1128)', () async {
// Instance order as reported by the router in the #1128 diagnostic log:
// instance 1 is the link-local fe80:: address.
stubWanStatus(
ipv6Enabled: true,
ipv6Addresses: const [
'fe80::7612:13ff:fe21:5394',
'2401:e180:8831:505f::1',
'2401:e180:8831:505f:7612:13ff:fe21:5394',
'2401:e180:8801:d79d:7612:13ff:fe21:5394',
],
);

final result = await svc.fetch();

// The widget shows ipv6Addresses.first, which must now be a global
// unicast address rather than the link-local fe80::.
expect(result.ipv6Addresses, hasLength(4));
expect(result.ipv6Addresses.first, '2401:e180:8831:505f::1');
expect(result.ipv6Addresses.last, 'fe80::7612:13ff:fe21:5394');
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading