mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-07-26 23:32:20 +00:00
feat: add DNS-over-HTTPS (DoH) support (experimental)
This commit is contained in:
parent
b6fc3edd23
commit
b53e869258
24 changed files with 1398 additions and 379 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
209
lib/services/http/doh/doh_providers.dart
Normal file
209
lib/services/http/doh/doh_providers.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
342
lib/services/http/doh/doh_resolver.dart
Normal file
342
lib/services/http/doh/doh_resolver.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue