feat: add DNS-over-HTTPS (DoH) support (experimental)

This commit is contained in:
Moustapha Kodjo Amadou 2026-01-20 14:38:46 +01:00
parent b6fc3edd23
commit b53e869258
24 changed files with 1398 additions and 379 deletions

View file

@ -563,5 +563,7 @@
"select_label_color": "Select {label} Color",
"default_user_agent": "Defaul user agent",
"forceLandscapeMode": "Force landscape mode",
"forceLandscapeModeSubtitle": "Force the player to use landscape orientation."
"forceLandscapeModeSubtitle": "Force the player to use landscape orientation.",
"dns_over_https": "DNS-over-HTTPS (DoH)",
"dns_provider": "DNS Provider"
}

View file

@ -3454,6 +3454,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Force the player to use landscape orientation.'**
String get forceLandscapeModeSubtitle;
/// No description provided for @dns_over_https.
///
/// In en, this message translates to:
/// **'DNS-over-HTTPS (DoH)'**
String get dns_over_https;
/// No description provided for @dns_provider.
///
/// In en, this message translates to:
/// **'DNS Provider'**
String get dns_provider;
}
class _AppLocalizationsDelegate

View file

@ -1788,4 +1788,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1794,4 +1794,10 @@ class AppLocalizationsAs extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1809,4 +1809,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1788,4 +1788,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1817,6 +1817,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}
/// The translations for Spanish Castilian, as used in Latin America and the Caribbean (`es_419`).

View file

@ -1817,4 +1817,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1794,4 +1794,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1800,4 +1800,10 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1814,4 +1814,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1765,4 +1765,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1812,6 +1812,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}
/// The translations for Portuguese, as used in Brazil (`pt_BR`).

View file

@ -1817,4 +1817,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1788,4 +1788,10 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1800,4 +1800,10 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle =>
'Force the player to use landscape orientation.';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -1745,4 +1745,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get forceLandscapeModeSubtitle => '强制播放器使用横屏方向。';
@override
String get dns_over_https => 'DNS-over-HTTPS (DoH)';
@override
String get dns_provider => 'DNS Provider';
}

View file

@ -175,6 +175,10 @@ class Settings {
String? customDns;
bool? doHEnabled;
int? doHProviderId;
String? btServerAddress;
int? btServerPort;
@ -383,6 +387,8 @@ class Settings {
this.enableAutoSkip,
this.aniSkipTimeoutLength,
this.customDns = "",
this.doHEnabled = false,
this.doHProviderId = 0,
this.btServerAddress = "127.0.0.1",
this.btServerPort,
this.fullScreenReader = true,
@ -590,6 +596,8 @@ class Settings {
enableAutoSkip = json['enableAutoSkip'];
aniSkipTimeoutLength = json['aniSkipTimeoutLength'];
customDns = json['customDns'];
doHEnabled = json['doHEnabled'];
doHProviderId = json['doHProviderId'];
btServerAddress = json['btServerAddress'];
btServerPort = json['btServerPort'];
customColorFilter = json['customColorFilter'] != null
@ -797,6 +805,8 @@ class Settings {
'enableAutoSkip': enableAutoSkip,
'aniSkipTimeoutLength': aniSkipTimeoutLength,
'customDns': customDns,
'doHEnabled': doHEnabled,
'doHProviderId': doHProviderId,
'btServerAddress': btServerAddress,
'btServerPort': btServerPort,
'fullScreenReader': fullScreenReader,

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@ import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/more/providers/algorithm_weights_state_provider.dart';
import 'package:mangayomi/modules/more/settings/general/providers/general_state_provider.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/modules/more/settings/general/providers/doh_provider_notifier.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class GeneralScreen extends ConsumerStatefulWidget {
const GeneralScreen({super.key});
@ -45,11 +47,91 @@ class _GeneralStateScreen extends ConsumerState<GeneralScreen> {
);
final rpcShowTitleState = ref.watch(rpcShowTitleStateProvider);
final rpcShowCoverImage = ref.watch(rpcShowCoverImageStateProvider);
final doHState = ref.watch(doHProviderStateProvider);
final availableProviders = ref.watch(availableDoHProvidersProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n!.general)),
body: SingleChildScrollView(
child: Column(
children: [
ExpansionTile(
title: Text(l10n.dns_over_https),
initiallyExpanded: doHState.enabled,
trailing: IgnorePointer(
child: Switch(value: doHState.enabled, onChanged: (_) {}),
),
onExpansionChanged: (value) => ref
.read(doHProviderStateProvider.notifier)
.setDoHEnabled(value),
children: [
ListTile(
title: Text(l10n.dns_provider),
subtitle: Text(
availableProviders[doHState.providerId ?? 1].name,
style: TextStyle(
fontSize: 11,
color: context.secondaryColor,
),
),
onTap: () {
final providerId = doHState.providerId ?? 1;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.dns_provider),
content: SizedBox(
width: context.width(0.8),
child: RadioGroup(
groupValue: providerId,
onChanged: (value) {
ref
.read(doHProviderStateProvider.notifier)
.setDoHProvider(value!);
if (context.mounted) {
Navigator.pop(context);
}
},
child: SuperListView.builder(
shrinkWrap: true,
itemCount: availableProviders.length,
itemBuilder: (context, index) {
final provider = availableProviders[index];
return RadioListTile(
dense: true,
contentPadding: const EdgeInsets.all(0),
value: provider.id,
title: Text(provider.name),
);
},
),
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(
l10n.cancel,
style: TextStyle(
color: context.primaryColor,
),
),
),
],
),
],
);
},
);
},
),
],
),
ListTile(
onTap: () => _showCustomDnsDialog(context, ref, customDns),
title: Text(l10n.custom_dns),

View file

@ -0,0 +1,65 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/services/http/doh/doh_providers.dart';
class DoHProviderState {
final bool enabled;
final int? providerId;
DoHProviderState({required this.enabled, this.providerId});
DoHProviderState copyWith({bool? enabled, int? providerId}) {
return DoHProviderState(
enabled: enabled ?? this.enabled,
providerId: providerId ?? this.providerId,
);
}
}
final doHProviderStateProvider =
NotifierProvider<DoHProviderNotifier, DoHProviderState>(() {
return DoHProviderNotifier();
});
class DoHProviderNotifier extends Notifier<DoHProviderState> {
@override
DoHProviderState build() {
final settings = isar.settings.getSync(227)!;
return DoHProviderState(
enabled: settings.doHEnabled ?? false,
providerId: settings.doHProviderId,
);
}
void setDoHEnabled(bool enabled) {
final settings = isar.settings.getSync(227)!;
settings.doHEnabled = enabled;
if (enabled && settings.doHProviderId == null) {
settings.doHProviderId = 0;
}
isar.writeTxnSync(() {
isar.settings.putSync(settings);
});
state = state.copyWith(enabled: enabled);
}
void setDoHProvider(int providerId) {
final provider = DoHProviders.byId[providerId];
if (provider == null) {
return;
}
final settings = isar.settings.getSync(227)!;
settings.doHProviderId = providerId;
settings.doHEnabled = true;
isar.writeTxnSync(() {
isar.settings.putSync(settings);
});
state = state.copyWith(enabled: true, providerId: providerId);
}
}
final availableDoHProvidersProvider = Provider<List<DoHProvider>>((ref) {
return DoHProviders.all;
});

View file

@ -0,0 +1,209 @@
/// Represents a DoH provider with URL and bootstrap DNS hosts
class DoHProvider {
/// Provider identifier (for persistence)
final int id;
/// User-friendly name
final String name;
/// DoH endpoint URL
final String url;
/// Bootstrap DNS hosts (IPs) to avoid circular resolution
/// Mix of IPv4 and IPv6 for fallback
final List<String> bootstrapIPs;
/// Description for UI
final String description;
/// Region/category info
final String region;
const DoHProvider({
required this.id,
required this.name,
required this.url,
required this.bootstrapIPs,
required this.description,
required this.region,
});
@override
String toString() => name;
}
/// All available DoH providers
final class DoHProviders {
/// Cloudflare DNS - Fast, privacy-first
static const cloudflare = DoHProvider(
id: 0,
name: 'Cloudflare',
url: 'https://cloudflare-dns.com/dns-query',
bootstrapIPs: [
'162.159.36.1',
'162.159.46.1',
'1.1.1.1',
'1.0.0.1',
'162.159.132.53',
'2606:4700:4700::1111',
'2606:4700:4700::1001',
'2606:4700:4700::0064',
'2606:4700:4700::6400',
],
description: 'Fast and privacy-first DNS',
region: 'Global',
);
/// Google Public DNS - Reliable, fast
static const google = DoHProvider(
id: 1,
name: 'Google',
url: 'https://dns.google/dns-query',
bootstrapIPs: [
'8.8.4.4',
'8.8.8.8',
'2001:4860:4860::8888',
'2001:4860:4860::8844',
],
description: 'Google Public DNS',
region: 'Global',
);
/// AdGuard DNS Unfiltered - No blocklists by default
static const adguard = DoHProvider(
id: 2,
name: 'AdGuard',
url: 'https://dns-unfiltered.adguard.com/dns-query',
bootstrapIPs: [
'94.140.14.140',
'94.140.14.141',
'2a10:50c0::1:ff',
'2a10:50c0::2:ff',
],
description: 'Unfiltered (no blocking)',
region: 'Global',
);
/// Quad9 DNS - Privacy + security
static const quad9 = DoHProvider(
id: 3,
name: 'Quad9',
url: 'https://dns.quad9.net/dns-query',
bootstrapIPs: ['9.9.9.9', '149.112.112.112', '2620:fe::fe', '2620:fe::9'],
description: 'Privacy-focused with security',
region: 'Global',
);
/// AliDNS - Optimized for Asia
static const alidns = DoHProvider(
id: 4,
name: 'AliDNS',
url: 'https://dns.alidns.com/dns-query',
bootstrapIPs: [
'223.5.5.5',
'223.6.6.6',
'2400:3200::1',
'2400:3200:baba::1',
],
description: 'Optimized for Asia',
region: 'China',
);
/// DNSPod DNS - China mainland
static const dnspod = DoHProvider(
id: 5,
name: 'DNSPod',
url: 'https://doh.pub/dns-query',
bootstrapIPs: ['1.12.12.12', '120.53.53.53'],
description: 'China mainland DNS',
region: 'China',
);
/// 360 DNS - China
static const dns360 = DoHProvider(
id: 6,
name: '360 DNS',
url: 'https://doh.360.cn/dns-query',
bootstrapIPs: [
'101.226.4.6',
'218.30.118.6',
'123.125.81.6',
'140.207.198.6',
'180.163.249.75',
'101.199.113.208',
'36.99.170.86',
],
description: 'China domestic DNS',
region: 'China',
);
/// Quad101 - Taiwan
static const quad101 = DoHProvider(
id: 7,
name: 'Quad101',
url: 'https://dns.twnic.tw/dns-query',
bootstrapIPs: ['101.101.101.101', '2001:de4::101', '2001:de4::102'],
description: 'Taiwan DNS service',
region: 'Taiwan',
);
/// Mullvad DNS - Privacy-focused VPN provider
static const mullvad = DoHProvider(
id: 8,
name: 'Mullvad',
url: 'https://dns.mullvad.net/dns-query',
bootstrapIPs: ['194.242.2.2', '2a07:e340::2'],
description: 'Privacy-focused VPN provider',
region: 'Global',
);
/// ControlD DNS - Unfiltered free option
static const controld = DoHProvider(
id: 9,
name: 'ControlD',
url: 'https://freedns.controld.com/p0',
bootstrapIPs: ['76.76.2.0', '76.76.10.0', '2606:1a40::', '2606:1a40:1::'],
description: 'Unfiltered (free option)',
region: 'Global',
);
/// Njalla DNS - Non-logging, uncensored
static const njalla = DoHProvider(
id: 10,
name: 'Njalla',
url: 'https://dns.njal.la/dns-query',
bootstrapIPs: ['95.215.19.53', '2001:67c:2354:2::53'],
description: 'Non-logging and uncensored',
region: 'Global',
);
/// Shecan DNS - Iran censorship bypass
static const shecan = DoHProvider(
id: 11,
name: 'Shecan',
url: 'https://free.shecan.ir/dns-query',
bootstrapIPs: ['178.22.122.100', '185.51.200.2'],
description: 'Iran censorship bypass',
region: 'Iran',
);
/// All providers in a list
static const List<DoHProvider> all = [
cloudflare,
google,
adguard,
quad9,
alidns,
dnspod,
dns360,
quad101,
mullvad,
controld,
njalla,
shecan,
];
static final Map<int, DoHProvider> byId = {
for (final provider in all) provider.id: provider,
};
}

View file

@ -0,0 +1,342 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:mangayomi/services/http/doh/doh_providers.dart';
/// Cache entry with dynamic TTL
class _CacheEntry {
final List<String> ips;
final DateTime timestamp;
final int ttl; // in seconds
_CacheEntry(this.ips, this.timestamp, {this.ttl = 300}); // default 5 min
bool get isExpired => DateTime.now().difference(timestamp).inSeconds > ttl;
}
/// Provider stats for circuit breaker
class _ProviderStats {
int successCount = 0;
int failureCount = 0;
DateTime lastFailure = DateTime.now();
bool isCircuitOpen = false;
double get successRate => (successCount + failureCount) == 0
? 100
: (successCount / (successCount + failureCount)) * 100;
bool shouldRetry() {
if (!isCircuitOpen) return true;
// Retry after 1 minute if circuit is open
return DateTime.now().difference(lastFailure).inSeconds > 60;
}
}
/// DoH (DNS-over-HTTPS) Resolver
class DoHResolver {
/// Cache for resolved domains with dynamic TTL
static final Map<String, _CacheEntry> _cache = {};
/// Provider statistics for circuit breaker
static final Map<int, _ProviderStats> _providerStats = {};
/// Rate limiting (max requests per second per host)
static final Map<String, List<DateTime>> _requestHistory = {};
static const int _maxRequestsPerSecond = 10;
static const Duration _requestTimeout = Duration(seconds: 5);
/// Resolve hostname using specified DoH provider with A and AAAA records
/// Uses bootstrap IPs to avoid circular DNS resolution
static Future<List<String>> resolve(
String host, {
DoHProvider? provider,
bool ipv6 = true,
}) async {
// Use default (Cloudflare) if not specified
provider ??= DoHProviders.cloudflare;
// Validate bootstrap IPs
if (provider.bootstrapIPs.isEmpty) {
_log('Provider ${provider.name} has no bootstrap IPs', isError: true);
return [];
}
// Rate limiting check
if (!_checkRateLimit(host)) {
_log('Rate limit exceeded for $host', isError: true);
return [];
}
// Check cache first (cache key includes both host and provider)
final cacheKey = '$host:${provider.id}';
final cached = _cache[cacheKey];
if (cached != null && !cached.isExpired) {
_log('Cache hit for $host on ${provider.name}');
return cached.ips;
}
try {
// Try resolution with specified provider
var ips = await _resolveFromProvider(host, provider);
// If no IPv4, try IPv6
if (ips.isEmpty && ipv6) {
_log('No IPv4 for $host, trying IPv6');
ips = await _resolveFromProvider(host, provider, recordType: 'AAAA');
}
if (ips.isNotEmpty) {
_getProviderStats(provider.id).successCount++;
// Cache with dynamic TTL (default 5 min)
_cache[cacheKey] = _CacheEntry(ips, DateTime.now(), ttl: 300);
return ips;
}
// If primary provider fails, try fallback
_log('Resolution failed with ${provider.name}, trying fallback');
return await _resolveWithFallback(host, provider, ipv6: ipv6);
} catch (e) {
_getProviderStats(provider.id).failureCount++;
_getProviderStats(provider.id).lastFailure = DateTime.now();
_log('Error resolving $host: $e', isError: true);
return [];
}
}
/// Resolve using specific provider via bootstrap IP
/// This avoids circular DNS dependency by connecting to bootstrap IP directly
static Future<List<String>> _resolveFromProvider(
String host,
DoHProvider provider, {
String recordType = 'A',
}) async {
// Check circuit breaker
final stats = _getProviderStats(provider.id);
if (!stats.shouldRetry()) {
_log('Provider ${provider.name} circuit breaker is open', isError: true);
return [];
}
final uri = Uri.parse(provider.url);
final providerHost = uri.host;
// Validate bootstrap IPs format
final validBootstrapIps = provider.bootstrapIPs
.where((ip) => _isValidIp(ip))
.toList();
if (validBootstrapIps.isEmpty) {
_log('No valid bootstrap IPs for ${provider.name}', isError: true);
return [];
}
// Try each bootstrap IP until one succeeds
for (int i = 0; i < validBootstrapIps.length; i++) {
final bootstrapIp = validBootstrapIps[i];
try {
final result = await _queryDoHViaBootstrapIp(
host,
provider,
bootstrapIp,
providerHost,
recordType,
);
if (result.isNotEmpty) {
_log(
'Resolved $host via ${provider.name} (bootstrap IP $i+1/${validBootstrapIps.length})',
);
return result;
}
} catch (e) {
_log('Bootstrap IP $i failed for ${provider.name}: $e');
continue;
}
}
stats.failureCount++;
stats.lastFailure = DateTime.now();
// Open circuit after 3 consecutive failures
if (stats.failureCount >= 3) {
stats.isCircuitOpen = true;
_log('Circuit breaker opened for ${provider.name}', isError: true);
}
return [];
}
/// Query DoH via specific bootstrap IP (direct IP connection, no DNS lookup)
static Future<List<String>> _queryDoHViaBootstrapIp(
String targetHost,
DoHProvider provider,
String bootstrapIp,
String providerHost,
String recordType,
) async {
final uri = Uri.parse(provider.url);
final client = HttpClient();
client.connectionTimeout = _requestTimeout;
try {
// Create request with bootstrap IP instead of hostname
final request = await client.getUrl(
Uri(
scheme: uri.scheme,
host: bootstrapIp, // Use bootstrap IP directly
port: uri.port,
path: uri.path,
query: 'name=$targetHost&type=$recordType',
),
);
// Set Host header to the actual provider hostname (required for HTTPS SNI)
request.headers.set('Host', providerHost);
request.headers.set('Accept', 'application/dns-json');
request.headers.set('User-Agent', 'Mangayomi/1.0');
final response = await request.close().timeout(_requestTimeout);
if (response.statusCode == 200) {
final body = await utf8.decodeStream(response);
return _parseDoHResponse(body);
}
_log('HTTP ${response.statusCode} from $bootstrapIp (${provider.name})');
return [];
} on SocketException {
// Connection failed with this bootstrap IP
_log('Socket error on $bootstrapIp');
rethrow;
} on TimeoutException {
// Timeout with this bootstrap IP
_log('Timeout on $bootstrapIp');
rethrow;
} finally {
client.close();
}
}
/// Fallback resolution with other providers
static Future<List<String>> _resolveWithFallback(
String host,
DoHProvider primary, {
bool ipv6 = true,
}) async {
// Try other providers in order
for (final provider in DoHProviders.all) {
if (provider.id == primary.id) continue;
// Skip providers with open circuit
final stats = _getProviderStats(provider.id);
if (!stats.shouldRetry()) {
_log('Skipping ${provider.name} (circuit open)');
continue;
}
try {
var ips = await _resolveFromProvider(host, provider);
if (ips.isEmpty && ipv6) {
ips = await _resolveFromProvider(host, provider, recordType: 'AAAA');
}
if (ips.isNotEmpty) {
stats.successCount++;
final cacheKey = '$host:${provider.id}';
_cache[cacheKey] = _CacheEntry(ips, DateTime.now(), ttl: 300);
_log('Fallback success with ${provider.name}');
return ips;
}
} catch (e) {
_log('Fallback failed with ${provider.name}: $e');
continue;
}
}
_log('All providers exhausted for $host', isError: true);
return [];
}
/// Parse DoH JSON response (RFC 8484 format) and extract TTL
static List<String> _parseDoHResponse(String jsonBody) {
try {
final json = jsonDecode(jsonBody) as Map<String, dynamic>;
final answers = json['Answer'] as List?;
if (answers != null && answers.isNotEmpty) {
final ips = answers
.map((answer) {
if (answer is Map && answer.containsKey('data')) {
return answer['data'] as String;
}
return null;
})
.whereType<String>()
.toList();
if (ips.isNotEmpty) {
// Extract TTL from first answer if available
final ttl = (answers.first is Map && answers.first.containsKey('TTL'))
? (answers.first['TTL'] as int?) ?? 300
: 300;
_log('Parsed ${ips.length} record(s) with TTL $ttl');
}
return ips;
}
} catch (e) {
_log('Failed to parse DoH response: $e', isError: true);
}
return [];
}
/// Check if IP address is valid (IPv4 or IPv6)
static bool _isValidIp(String ip) {
try {
InternetAddress(ip);
return true;
} catch (e) {
return false;
}
}
/// Rate limiting: check if request is allowed
static bool _checkRateLimit(String host) {
final now = DateTime.now();
final cutoffTime = now.subtract(Duration(seconds: 1));
// Get or create request history for this host
_requestHistory[host] ??= [];
final history = _requestHistory[host]!;
// Remove old entries outside 1-second window
history.removeWhere((t) => t.isBefore(cutoffTime));
// Check if limit exceeded
if (history.length >= _maxRequestsPerSecond) {
return false;
}
// Record new request
history.add(now);
return true;
}
/// Get or create provider stats
static _ProviderStats _getProviderStats(int providerId) {
return _providerStats.putIfAbsent(providerId, () => _ProviderStats());
}
static void _log(String message, {bool isError = false}) {
final timestamp = DateTime.now().toIso8601String();
if (kDebugMode) {
if (isError) {
print('❌ [$timestamp] DoH: $message');
} else {
// ignore: avoid_print
print('✓ [$timestamp] DoH: $message');
}
}
}
}

View file

@ -14,6 +14,8 @@ import 'package:http/io_client.dart';
import 'package:mangayomi/services/http/rhttp/src/model/settings.dart';
import 'package:mangayomi/utils/log/log.dart';
import 'package:mangayomi/services/http/rhttp/rhttp.dart' as rhttp;
import 'package:mangayomi/services/http/doh/doh_resolver.dart';
import 'package:mangayomi/services/http/doh/doh_providers.dart';
class MClient {
MClient();
@ -56,18 +58,31 @@ class MClient {
rhttp.ClientSettings? settings,
bool showCloudFlareError = true,
}) {
final clientSettings = customDns == null || customDns!.trim().isEmpty
? settings
: settings?.copyWith(
dnsSettings: DnsSettings.dynamic(
resolver: (host) async => [customDns!],
),
) ??
ClientSettings(
dnsSettings: DnsSettings.dynamic(
resolver: (host) async => [customDns!],
),
);
final appSettings = isar.settings.getSync(227);
final useDoH = appSettings?.doHEnabled ?? false;
final doHProviderId = appSettings?.doHProviderId;
DnsSettings? dnsSettings;
if (useDoH && doHProviderId != null) {
// Use DoH resolver with specific provider
final provider = DoHProviders.byId[doHProviderId];
if (provider != null) {
dnsSettings = DnsSettings.dynamic(
resolver: (host) => DoHResolver.resolve(host, provider: provider),
);
}
} else if (customDns != null && customDns!.trim().isNotEmpty) {
// Fallback to custom static DNS
dnsSettings = DnsSettings.dynamic(resolver: (host) async => [customDns!]);
}
// Apply DNS settings if configured
final clientSettings = dnsSettings != null
? settings?.copyWith(dnsSettings: dnsSettings) ??
ClientSettings(dnsSettings: dnsSettings)
: settings;
return InterceptedClient.build(
client: httpClient(settings: clientSettings, reqcopyWith: reqcopyWith),
retryPolicy: ResolveCloudFlareChallenge(showCloudFlareError),