286 lines
8.7 KiB
Dart
286 lines
8.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
class TrustAPPHttpdns {
|
|
static const MethodChannel _channel = MethodChannel('TrustAPP_httpdns');
|
|
|
|
/// Initialize the SDK.
|
|
/// [apiUrl] is the unified endpoint URL, e.g. "https://httpdns.example.com:8445".
|
|
/// [appId] and [secretKey] are required for authentication.
|
|
static Future<bool> init({
|
|
required String appId,
|
|
String? apiUrl,
|
|
@Deprecated('Use apiUrl instead') String? primaryServiceHost,
|
|
@Deprecated('Use apiUrl instead') String? backupServiceHost,
|
|
@Deprecated('Use apiUrl instead') int servicePort = 443,
|
|
String? secretKey,
|
|
}) async {
|
|
final String normalizedAppId = appId.trim();
|
|
if (normalizedAppId.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
final Map<String, dynamic> args = <String, dynamic>{
|
|
'appId': normalizedAppId,
|
|
if (apiUrl != null && apiUrl.trim().isNotEmpty) 'apiUrl': apiUrl.trim(),
|
|
if (primaryServiceHost != null && primaryServiceHost.trim().isNotEmpty)
|
|
'primaryServiceHost': primaryServiceHost.trim(),
|
|
if (backupServiceHost != null && backupServiceHost.trim().isNotEmpty)
|
|
'backupServiceHost': backupServiceHost.trim(),
|
|
'servicePort': servicePort,
|
|
if (secretKey != null && secretKey.isNotEmpty) 'secretKey': secretKey,
|
|
};
|
|
|
|
final bool? ok = await _channel.invokeMethod<bool>('initialize', args);
|
|
return ok ?? false;
|
|
}
|
|
|
|
static Future<bool> build() async {
|
|
final bool? ok = await _channel.invokeMethod<bool>('build');
|
|
return ok ?? false;
|
|
}
|
|
|
|
static Future<void> setLogEnabled(bool enabled) async {
|
|
await _channel.invokeMethod<void>('setLogEnabled', <String, dynamic>{'enabled': enabled});
|
|
}
|
|
|
|
static Future<void> setPersistentCacheIPEnabled(bool enabled,
|
|
{int? discardExpiredAfterSeconds}) async {
|
|
await _channel.invokeMethod<void>('setPersistentCacheIPEnabled', <String, dynamic>{
|
|
'enabled': enabled,
|
|
if (discardExpiredAfterSeconds != null)
|
|
'discardExpiredAfterSeconds': discardExpiredAfterSeconds,
|
|
});
|
|
}
|
|
|
|
static Future<void> setReuseExpiredIPEnabled(bool enabled) async {
|
|
await _channel
|
|
.invokeMethod<void>('setReuseExpiredIPEnabled', <String, dynamic>{'enabled': enabled});
|
|
}
|
|
|
|
static Future<void> setHttpsRequestEnabled(bool enabled) async {
|
|
await _channel
|
|
.invokeMethod<void>('setHttpsRequestEnabled', <String, dynamic>{'enabled': enabled});
|
|
}
|
|
|
|
static Future<Map<String, List<String>>> resolveHostSyncNonBlocking(
|
|
String hostname, {
|
|
String ipType = 'auto', // auto/ipv4/ipv6/both
|
|
}) async {
|
|
final Map<dynamic, dynamic>? res =
|
|
await _channel.invokeMethod<Map<dynamic, dynamic>>('resolveHostSyncNonBlocking',
|
|
<String, dynamic>{
|
|
'hostname': hostname,
|
|
'ipType': ipType,
|
|
});
|
|
|
|
final Map<String, List<String>> out = <String, List<String>>{
|
|
'ipv4': <String>[],
|
|
'ipv6': <String>[],
|
|
};
|
|
if (res == null) {
|
|
return out;
|
|
}
|
|
|
|
final dynamic v4 = res['ipv4'];
|
|
final dynamic v6 = res['ipv6'];
|
|
if (v4 is List) {
|
|
out['ipv4'] = v4.map((e) => e.toString()).toList();
|
|
}
|
|
if (v6 is List) {
|
|
out['ipv6'] = v6.map((e) => e.toString()).toList();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// V1 resolve API:
|
|
/// qtype supports A / AAAA, optional cip for route simulation.
|
|
static Future<Map<String, dynamic>> resolveHost(
|
|
String hostname, {
|
|
String qtype = 'A',
|
|
String? cip,
|
|
}) async {
|
|
final Map<dynamic, dynamic>? res =
|
|
await _channel.invokeMethod<Map<dynamic, dynamic>>('resolveHostV1', <String, dynamic>{
|
|
'hostname': hostname,
|
|
'qtype': qtype,
|
|
if (cip != null && cip.trim().isNotEmpty) 'cip': cip.trim(),
|
|
});
|
|
|
|
final Map<String, dynamic> out = <String, dynamic>{
|
|
'ipv4': <String>[],
|
|
'ipv6': <String>[],
|
|
'ttl': 0,
|
|
};
|
|
if (res == null) {
|
|
return out;
|
|
}
|
|
final dynamic v4 = res['ipv4'];
|
|
final dynamic v6 = res['ipv6'];
|
|
if (v4 is List) {
|
|
out['ipv4'] = v4.map((e) => e.toString()).toList();
|
|
}
|
|
if (v6 is List) {
|
|
out['ipv6'] = v6.map((e) => e.toString()).toList();
|
|
}
|
|
final dynamic ttl = res['ttl'];
|
|
if (ttl is int) {
|
|
out['ttl'] = ttl;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
static Future<void> setPreResolveHosts(List<String> hosts, {String ipType = 'auto'}) async {
|
|
await _channel.invokeMethod<void>('setPreResolveHosts', <String, dynamic>{
|
|
'hosts': hosts,
|
|
'ipType': ipType,
|
|
});
|
|
}
|
|
|
|
static Future<void> setPreResolveAfterNetworkChanged(bool enabled) async {
|
|
await _channel.invokeMethod<void>('setPreResolveAfterNetworkChanged', <String, dynamic>{
|
|
'enabled': enabled,
|
|
});
|
|
}
|
|
|
|
static Future<void> setIPRankingList(Map<String, int> hostPortMap) async {
|
|
await _channel
|
|
.invokeMethod<void>('setIPRankingList', <String, dynamic>{'hostPortMap': hostPortMap});
|
|
}
|
|
|
|
static Future<String?> getSessionId() async {
|
|
return _channel.invokeMethod<String>('getSessionId');
|
|
}
|
|
|
|
static Future<void> cleanAllHostCache() async {
|
|
await _channel.invokeMethod<void>('cleanAllHostCache');
|
|
}
|
|
|
|
static TrustAPPHttpdnsHttpAdapter createHttpAdapter({
|
|
TrustAPPHttpdnsAdapterOptions options = const TrustAPPHttpdnsAdapterOptions(),
|
|
}) {
|
|
return TrustAPPHttpdnsHttpAdapter._(options);
|
|
}
|
|
}
|
|
|
|
class TrustAPPHttpdnsAdapterOptions {
|
|
final String ipType;
|
|
final int connectTimeoutMs;
|
|
final int readTimeoutMs;
|
|
final bool allowInsecureCertificatesForDebugOnly;
|
|
|
|
const TrustAPPHttpdnsAdapterOptions({
|
|
this.ipType = 'auto',
|
|
this.connectTimeoutMs = 3000,
|
|
this.readTimeoutMs = 5000,
|
|
this.allowInsecureCertificatesForDebugOnly = false,
|
|
});
|
|
}
|
|
|
|
class TrustAPPHttpdnsRequestResult {
|
|
final int statusCode;
|
|
final Map<String, List<String>> headers;
|
|
final Uint8List body;
|
|
final String usedIp;
|
|
|
|
const TrustAPPHttpdnsRequestResult({
|
|
required this.statusCode,
|
|
required this.headers,
|
|
required this.body,
|
|
required this.usedIp,
|
|
});
|
|
}
|
|
|
|
class TrustAPPHttpdnsHttpAdapter {
|
|
final TrustAPPHttpdnsAdapterOptions _options;
|
|
|
|
TrustAPPHttpdnsHttpAdapter._(this._options);
|
|
|
|
/// Fixed behavior:
|
|
/// 1) resolve host by HTTPDNS
|
|
/// 2) connect to IP:443
|
|
/// 3) keep HTTP Host as original domain
|
|
/// 4) no fallback to domain connect
|
|
Future<TrustAPPHttpdnsRequestResult> request(
|
|
Uri uri, {
|
|
String method = 'GET',
|
|
Map<String, String>? headers,
|
|
List<int>? body,
|
|
}) async {
|
|
if (uri.host.isEmpty) {
|
|
throw const HttpException('HOST_ROUTE_REJECTED: host is empty');
|
|
}
|
|
if (uri.scheme.toLowerCase() != 'https') {
|
|
throw const HttpException('TLS_EMPTY_SNI_FAILED: only https is supported');
|
|
}
|
|
|
|
final Map<String, List<String>> resolved = await TrustAPPHttpdns.resolveHostSyncNonBlocking(
|
|
uri.host,
|
|
ipType: _options.ipType,
|
|
);
|
|
final List<String> ips = <String>[
|
|
...?resolved['ipv4'],
|
|
...?resolved['ipv6'],
|
|
];
|
|
if (ips.isEmpty) {
|
|
throw const HttpException('NO_IP_AVAILABLE: HTTPDNS returned empty ip list');
|
|
}
|
|
|
|
Object? lastError;
|
|
for (final String ip in ips) {
|
|
final HttpClient client = HttpClient();
|
|
client.connectionTimeout = Duration(milliseconds: _options.connectTimeoutMs);
|
|
if (_options.allowInsecureCertificatesForDebugOnly) {
|
|
client.badCertificateCallback = (_, __, ___) => true;
|
|
}
|
|
|
|
try {
|
|
final Uri target = uri.replace(
|
|
host: ip,
|
|
port: uri.hasPort ? uri.port : 443,
|
|
);
|
|
final HttpClientRequest req = await client
|
|
.openUrl(method, target)
|
|
.timeout(Duration(milliseconds: _options.connectTimeoutMs));
|
|
req.headers.host = uri.host;
|
|
headers?.forEach((String key, String value) {
|
|
if (key.toLowerCase() == 'host') {
|
|
return;
|
|
}
|
|
req.headers.set(key, value);
|
|
});
|
|
if (body != null && body.isNotEmpty) {
|
|
req.add(body);
|
|
}
|
|
|
|
final HttpClientResponse resp =
|
|
await req.close().timeout(Duration(milliseconds: _options.readTimeoutMs));
|
|
final List<int> payload =
|
|
await resp.fold<List<int>>(<int>[], (List<int> previous, List<int> element) {
|
|
previous.addAll(element);
|
|
return previous;
|
|
});
|
|
final Map<String, List<String>> responseHeaders = <String, List<String>>{};
|
|
resp.headers.forEach((String name, List<String> values) {
|
|
responseHeaders[name] = values;
|
|
});
|
|
return TrustAPPHttpdnsRequestResult(
|
|
statusCode: resp.statusCode,
|
|
headers: responseHeaders,
|
|
body: Uint8List.fromList(payload),
|
|
usedIp: ip,
|
|
);
|
|
} catch (e) {
|
|
lastError = e;
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
}
|
|
|
|
throw HttpException('TLS_EMPTY_SNI_FAILED: all ip connect attempts failed, error=$lastError');
|
|
}
|
|
}
|