TF-3976 Support DNS SRV resolvers without Cloudflare/Google dependency
This commit is contained in:
@@ -1,23 +1,23 @@
|
||||
import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_url.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart';
|
||||
import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart';
|
||||
|
||||
class LoginDataSourceImpl implements LoginDataSource {
|
||||
|
||||
final DNSService _dnsService;
|
||||
final DnsLookupManager _dnsLookupManager;
|
||||
final ExceptionThrower _exceptionThrower;
|
||||
|
||||
LoginDataSourceImpl(
|
||||
this._dnsService,
|
||||
this._dnsLookupManager,
|
||||
this._exceptionThrower
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> dnsLookupToGetJmapUrl(String emailAddress) {
|
||||
return Future.sync(() async {
|
||||
return await _dnsService.getJmapUrl(emailAddress);
|
||||
return await _dnsLookupManager.lookupJmapUrl(emailAddress);
|
||||
}).catchError(_exceptionThrower.throwException);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:core/utils/build_utils.dart';
|
||||
import 'package:super_dns_client/super_dns_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
|
||||
|
||||
/// Handles DNS SRV lookups for JMAP service discovery.
|
||||
///
|
||||
/// The manager attempts lookups in order of priority:
|
||||
/// **System → Public UDP → Public DoH → Cloud (Google/Cloudflare)**.
|
||||
class DnsLookupManager {
|
||||
static const String _jmapServicePrefix = '_jmap._tcp';
|
||||
static const Duration _defaultTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Builds the JMAP SRV hostname from [emailAddress].
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// input : user@example.com
|
||||
/// output: _jmap._tcp.example.com
|
||||
/// ```
|
||||
String buildJmapHostName(String emailAddress) {
|
||||
final parts = emailAddress.split('@');
|
||||
if (parts.length != 2 || parts[1].isEmpty) {
|
||||
throw ArgumentError('Invalid email address: $emailAddress');
|
||||
}
|
||||
return '$_jmapServicePrefix.${parts[1]}';
|
||||
}
|
||||
|
||||
/// Creates the appropriate [DnsClient] for the given [priority].
|
||||
DnsClient createClient(DnsLookupPriority priority) {
|
||||
const debug = BuildUtils.isDebugMode;
|
||||
switch (priority) {
|
||||
case DnsLookupPriority.system:
|
||||
return SystemUdpSrvClient(debugMode: debug);
|
||||
case DnsLookupPriority.publicUdp:
|
||||
return PublicUdpSrvClient(debugMode: debug);
|
||||
case DnsLookupPriority.publicDoh:
|
||||
return DnsOverHttpsBinaryClient(debugMode: debug);
|
||||
case DnsLookupPriority.cloud:
|
||||
return DnsOverHttps.google(debugMode: debug);
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts SRV resolution for the JMAP hostname derived from [emailAddress].
|
||||
///
|
||||
/// Each lookup attempt will timeout after [_defaultTimeout] seconds.
|
||||
/// Returns the first successfully resolved hostname, or an empty string if all fail.
|
||||
Future<String> lookupJmapUrl(String emailAddress) async {
|
||||
final jmapHostName = buildJmapHostName(emailAddress);
|
||||
log('$runtimeType::lookupJmapUrl → Resolving SRV for: $jmapHostName');
|
||||
|
||||
final priorities = List.of(DnsLookupPriority.values)
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
|
||||
for (final priority in priorities) {
|
||||
final client = createClient(priority);
|
||||
log('$runtimeType::lookupJmapUrl → 🔍 Trying ${priority.label} (timeout: ${_defaultTimeout.inSeconds}s)...');
|
||||
|
||||
try {
|
||||
final records = client is DnsOverHttps
|
||||
? await client.lookupSrvParallel(jmapHostName).timeout(
|
||||
_defaultTimeout,
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||
},
|
||||
)
|
||||
: await client.lookupSrv(jmapHostName).timeout(
|
||||
_defaultTimeout,
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
|
||||
},
|
||||
);
|
||||
|
||||
if (records.isNotEmpty) {
|
||||
final target = records.first.target;
|
||||
log('$runtimeType::lookupJmapUrl → ✅ Success via ${priority.label}: $target');
|
||||
return target;
|
||||
}
|
||||
|
||||
log('$runtimeType::lookupJmapUrl → ⚠️ No records via ${priority.label}, continuing...');
|
||||
} on TimeoutException catch (_) {
|
||||
logError(
|
||||
'$runtimeType::lookupJmapUrl → ⏱️ ${priority.label} lookup timed out');
|
||||
} catch (error, stack) {
|
||||
logError(
|
||||
'$runtimeType::lookupJmapUrl → ❌ ${priority.label} lookup failed: $error, $stack');
|
||||
}
|
||||
}
|
||||
|
||||
log('$runtimeType::lookupJmapUrl → 🚨 All DNS lookups failed for $jmapHostName');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Represents the priority order and description of different DNS lookup modes.
|
||||
///
|
||||
/// Priority level increases with fallback order:
|
||||
/// 1 → System default
|
||||
/// 2 → Public resolvers (UDP/TCP or DOH)
|
||||
/// 3 → Cloud resolvers (Google/Cloudflare)
|
||||
enum DnsLookupPriority {
|
||||
/// Uses the device's system-configured DNS (e.g., from ISP or OS settings).
|
||||
system(1, 'System Default'),
|
||||
|
||||
/// Uses open DNS resolvers accessible via UDP/TCP (e.g., Quad9, OpenDNS).
|
||||
publicUdp(2, 'Public DNS (UDP/TCP)'),
|
||||
|
||||
/// Uses DNS-over-HTTPS (DoH) resolvers for secure name resolution.
|
||||
publicDoh(2, 'Public DNS (DoH)'),
|
||||
|
||||
/// Uses Google or Cloudflare DNS resolvers.
|
||||
cloud(3, 'Cloud DNS (Google/Cloudflare)');
|
||||
|
||||
/// The lookup priority (lower means higher priority).
|
||||
final int priority;
|
||||
|
||||
/// A human-readable description for UI or logging.
|
||||
final String label;
|
||||
|
||||
const DnsLookupPriority(this.priority, this.label);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:dns_client/dns_client.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
|
||||
|
||||
class DNSService {
|
||||
static const String _jmapServiceName = '_jmap._tcp';
|
||||
|
||||
Future<String> _dnsLookupToUrlFromSRVType({required String hostName}) async {
|
||||
try {
|
||||
final url = await _dnsLookupToUrlByGoogle(hostName: hostName);
|
||||
if (url.isEmpty) {
|
||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _dnsLookupToUrlByGoogle({required String hostName}) async {
|
||||
final dns = DnsOverHttps.google();
|
||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
||||
if (listData.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
||||
}
|
||||
|
||||
Future<String> _dnsLookupToUrlByCloudflare({required String hostName}) async {
|
||||
final dns = DnsOverHttps.cloudflare();
|
||||
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
|
||||
if (listData.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
return _parsingUrlFromDataResourceRecord(listData.first);
|
||||
}
|
||||
|
||||
String _parsingUrlFromDataResourceRecord(String data) {
|
||||
if (data.isEmpty) {
|
||||
throw NotFoundDataResourceRecordException();
|
||||
}
|
||||
final listFieldData = data.split(' ');
|
||||
if (listFieldData.isEmpty) {
|
||||
throw NotFoundUrlException();
|
||||
}
|
||||
final url = _removeDotAtEndOfString(listFieldData.last);
|
||||
log('DNSService::_parsingUrlFromDataResourceRecord:url: $url');
|
||||
if (url.trim().isEmpty) {
|
||||
throw NotFoundUrlException();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<String> getJmapUrl(String emailAddress) async {
|
||||
final domainName = emailAddress.split('@')[1];
|
||||
final jmapHostName = '$_jmapServiceName.$domainName';
|
||||
log('DNSHandler::getJmapUrl:jmapHostName: $jmapHostName');
|
||||
return await _dnsLookupToUrlFromSRVType(hostName: jmapHostName);
|
||||
}
|
||||
|
||||
String _removeDotAtEndOfString(String value) {
|
||||
if (value.lastIndexOf('.') == value.length - 1) {
|
||||
return value.substring(0, value.length - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.da
|
||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
|
||||
import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart';
|
||||
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
|
||||
@@ -74,7 +74,7 @@ class LoginBindings extends BaseBindings {
|
||||
Get.find<CacheExceptionThrower>()
|
||||
));
|
||||
Get.lazyPut(() => LoginDataSourceImpl(
|
||||
Get.find<DNSService>(),
|
||||
Get.find<DnsLookupManager>(),
|
||||
Get.find<RemoteExceptionThrower>(),
|
||||
));
|
||||
Get.lazyPut(() => SaasAuthenticationDataSourceImpl(
|
||||
|
||||
Reference in New Issue
Block a user