JJ_MKVMaker/lib/core/dns_resolver.dart

343 lines
14 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import '../l10n/tr.dart';
/// DNS 서버 설정 (환경 설정 > 네트워크 · DNS).
/// - 'system': 기기 (Windows · Android) 의 DNS 를 그대로 (끄기)
/// - 'custom': 직접 지정한 서버 ([primary] · [secondary], 처음 8.8.8.8 · 8.8.4.4 - 사용자 결정 10/10)
/// - 'doh': DNS over HTTPS ([dohUrl], 처음 https://dns.google/dns-query). DoH 서버 이름은 [primary] · [secondary] 로 찾는다
class DnsConfig {
final String mode;
final String primary;
final String secondary;
final String dohUrl;
const DnsConfig({required this.mode, this.primary = '', this.secondary = '', this.dohUrl = ''});
static const modes = ['system', 'custom', 'doh'];
static const defaultMode = 'custom';
static const defaultPrimary = '8.8.8.8';
static const defaultSecondary = '8.8.4.4';
static const defaultDohUrl = 'https://dns.google/dns-query';
const DnsConfig.system() : this(mode: 'system');
bool get forced => mode != 'system';
/// 쓸 수 있는 직접 지정 서버 (적힌 순서대로, 틀린 값은 뺀다)
List<InternetAddress> get servers => [
for (final s in [primary, secondary]) ?parseDnsServer(s),
];
/// 캐시 열쇠 (설정이 바뀌면 예전 답을 쓰지 않게)
String get key => '$mode|$primary|$secondary|${mode == 'doh' ? dohUrl : ''}';
/// 화면에 보일 이름: "8.8.8.8, 8.8.4.4" · DoH 주소
String get label => mode == 'doh' ? dohUrl : [primary, secondary].where((s) => s.trim().isNotEmpty).join(', ');
}
/// DNS 서버 주소 (IPv4 · IPv6) 를 읽는다. 틀리면 null
InternetAddress? parseDnsServer(String s) {
var t = s.trim();
if (t.startsWith('[') && t.endsWith(']')) t = t.substring(1, t.length - 1);
if (t.isEmpty) return null;
final a = InternetAddress.tryParse(t);
if (a == null || a.type == InternetAddressType.unix) return null;
// 0.0.0.0 · :: 는 서버가 아니다
if (a.rawAddress.every((b) => b == 0)) return null;
return a;
}
/// DoH 주소가 쓸 수 있는 모양인지 (https:// 와 서버 이름)
bool isValidDohUrl(String s) {
final u = Uri.tryParse(s.trim().replaceAll('{?dns}', ''));
return u != null && u.isScheme('https') && u.host.isNotEmpty;
}
/// 이름 풀기 실패. [unreachable] 은 DNS 서버에 닿지 않은 것 (서버가 "그런 이름 없음" 이라고 답한 것과 구분)
class DnsException implements Exception {
final String message;
final bool unreachable;
/// 물어본 서버 (화면 · 작업 기록에 보임)
final String server;
const DnsException(this.message, {this.unreachable = false, this.server = ''});
@override
String toString() => message;
}
/// DNS 응답에서 꺼낸 주소들과 가장 짧은 TTL (초)
class DnsAnswer {
final List<InternetAddress> addresses;
final int ttl;
final int rcode;
/// 답이 잘렸다 (TC) - TCP 로 다시 묻는다
final bool truncated;
const DnsAnswer(this.addresses, this.ttl, this.rcode, {this.truncated = false});
}
/// DNS 질문 (RFC 1035) 하나: [type] 1 = A, 28 = AAAA
Uint8List buildDnsQuery(String host, int type, {int? id}) {
final b = BytesBuilder();
final qid = id ?? math.Random.secure().nextInt(0x10000);
b.add([qid >> 8, qid & 0xff, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); // RD, 질문 1
for (final label in host.split('.')) {
if (label.isEmpty) continue;
final bytes = label.codeUnits.any((c) => c > 127) ? Uri.encodeComponent(label).codeUnits : label.codeUnits;
if (bytes.length > 63) throw DnsException(trf('주소 이름이 너무 깁니다: {0}', [host]));
b.addByte(bytes.length);
b.add(bytes);
}
b.add([0, type >> 8, type & 0xff, 0, 1]);
return b.toBytes();
}
/// DNS 응답 읽기 (이름 줄임 · CNAME 사슬 포함). 응답이 질문 [id] 와 다르거나 깨졌으면 FormatException
DnsAnswer parseDnsResponse(Uint8List m, {int? id}) {
if (m.length < 12) throw const FormatException('short');
if (id != null && ((m[0] << 8) | m[1]) != id) throw const FormatException('id');
if (m[2] & 0x80 == 0) throw const FormatException('not a response');
final rcode = m[3] & 0x0f;
final qd = (m[4] << 8) | m[5];
final an = (m[6] << 8) | m[7];
var i = 12;
int skipName(int at) {
var j = at;
while (true) {
if (j >= m.length) throw const FormatException('name');
final len = m[j];
if (len == 0) return j + 1;
if (len & 0xc0 == 0xc0) return j + 2; // 줄임 표시
j += len + 1;
}
}
for (var q = 0; q < qd; q++) {
i = skipName(i) + 4;
}
final out = <InternetAddress>[];
var ttl = 1 << 30;
for (var a = 0; a < an; a++) {
i = skipName(i);
if (i + 10 > m.length) throw const FormatException('answer');
final type = (m[i] << 8) | m[i + 1];
final t = (m[i + 4] << 24) | (m[i + 5] << 16) | (m[i + 6] << 8) | m[i + 7];
final len = (m[i + 8] << 8) | m[i + 9];
i += 10;
if (i + len > m.length) throw const FormatException('rdata');
if ((type == 1 && len == 4) || (type == 28 && len == 16)) {
out.add(InternetAddress.fromRawAddress(Uint8List.fromList(m.sublist(i, i + len))));
ttl = math.min(ttl, t);
}
i += len;
}
return DnsAnswer(out, out.isEmpty ? 0 : ttl, rcode, truncated: m[2] & 0x02 != 0);
}
/// 정한 DNS 서버로 이름을 푼다 (UDP, 잘리면 TCP · 또는 DoH). TTL 동안 기억한다.
/// 서버에 닿지 않으면 [DnsException] (unreachable) - 기기 DNS 로 몰래 바꾸지 않는다.
class DnsResolver {
final DnsConfig Function() config;
/// 시험용: DNS 서버 포트 (보통 53)
final int port;
final Duration timeout;
/// 시험용: DoH 에 쓸 HttpClient
final HttpClient Function()? dohClient;
/// 어느 서버가 답했는지 (작업 기록 · 시험)
void Function(String host, String server)? onAnswer;
DnsResolver({required this.config, this.port = 53, this.timeout = const Duration(milliseconds: 2500), this.dohClient});
final _cache = <String, (List<InternetAddress>, DateTime)>{};
final _pending = <String, Future<List<InternetAddress>>>{};
void clearCache() => _cache.clear();
/// [host] 의 주소들 (IPv4 먼저)
Future<List<InternetAddress>> lookup(String host) {
final c = config();
final name = host.toLowerCase().replaceAll(RegExp(r'\.$'), '');
final key = '${c.key}|$name';
final hit = _cache[key];
if (hit != null && hit.$2.isAfter(DateTime.now())) return Future.value(hit.$1);
return _pending[key] ??= _resolve(c, name).then((r) {
if (_cache.length > 2000) _cache.clear();
_cache[key] = (r.$1, DateTime.now().add(Duration(seconds: r.$2.clamp(5, 3600))));
return r.$1;
}).whenComplete(() {
// 지운 값 (이 Future 자신) 을 돌려주면 whenComplete 가 자기를 기다린다
_pending.remove(key);
});
}
Future<(List<InternetAddress>, int)> _resolve(DnsConfig c, String name) async {
final results = await Future.wait([_ask(c, name, 1), _ask(c, name, 28)].map((f) => f.then<Object>((v) => v, onError: (Object e) => e)));
final ok = results.whereType<(DnsAnswer, String)>().toList();
final addrs = [for (final r in ok) ...r.$1.addresses];
if (addrs.isNotEmpty) {
onAnswer?.call(name, ok.first.$2);
final ttl = ok.where((r) => r.$1.addresses.isNotEmpty).map((r) => r.$1.ttl).reduce(math.min);
return (addrs, ttl);
}
// 둘 다 닿지 않음 → 서버 문제. 답은 왔는데 주소가 없음 → 그런 이름 없음
final errors = results.whereType<DnsException>().toList();
if (ok.isEmpty && errors.isNotEmpty) throw errors.first;
final server = ok.isNotEmpty ? ok.first.$2 : c.label;
throw DnsException(trf('이름을 찾을 수 없습니다: {0} (DNS {1})', [name, server]), server: server);
}
/// 서버들에 차례로 묻는다 (첫 서버가 답하지 않으면 다음 서버)
Future<(DnsAnswer, String)> _ask(DnsConfig c, String name, int type) async {
if (c.mode == 'doh') return (await _doh(c, name, type), c.dohUrl);
final servers = c.servers;
if (servers.isEmpty) {
throw DnsException(tr('DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)'), unreachable: true);
}
Object? last;
for (final s in servers) {
try {
return (await _udp(s, name, type), s.address);
} catch (e) {
last = e;
}
}
// 작업 기록 · 화면에 예외 이름 ("TimeoutException:") 없이 (a4 932)
final why = switch (last) {
TimeoutException(:final message) => message ?? tr('답이 없습니다'),
SocketException(:final message) => message,
_ => '$last',
};
throw DnsException(trf('DNS 서버 ({0}) 에 닿지 않습니다: {1}', [c.label, why]), unreachable: true, server: c.label);
}
Future<DnsAnswer> _udp(InternetAddress server, String name, int type) async {
final id = math.Random.secure().nextInt(0x10000);
final q = buildDnsQuery(name, type, id: id);
final sock = await RawDatagramSocket.bind(
server.type == InternetAddressType.IPv6 ? InternetAddress.anyIPv6 : InternetAddress.anyIPv4, 0);
try {
final done = Completer<DnsAnswer>();
sock.listen((ev) {
if (ev != RawSocketEvent.read) return;
final d = sock.receive();
if (d == null || d.address != server || done.isCompleted) return;
try {
done.complete(parseDnsResponse(d.data, id: id));
} catch (_) {} // 다른 질문의 답 · 깨진 답은 버리고 기다린다
});
if (sock.send(q, server, port) <= 0) throw SocketException(tr('보내지 못했습니다'));
final r = await done.future.timeout(timeout, onTimeout: () => throw TimeoutException(tr('답이 없습니다')));
// 잘린 답 (TC) 은 TCP 로 다시
return r.truncated ? await _tcp(server, name, type) : r;
} finally {
sock.close();
}
}
Future<DnsAnswer> _tcp(InternetAddress server, String name, int type) async {
final id = math.Random.secure().nextInt(0x10000);
final q = buildDnsQuery(name, type, id: id);
final s = await Socket.connect(server, port, timeout: timeout);
try {
s.add([q.length >> 8, q.length & 0xff, ...q]);
final buf = BytesBuilder();
await for (final chunk in s.timeout(timeout)) {
buf.add(chunk);
final b = buf.toBytes();
if (b.length >= 2 && b.length >= 2 + ((b[0] << 8) | b[1])) {
return parseDnsResponse(Uint8List.sublistView(b, 2, 2 + ((b[0] << 8) | b[1])), id: id);
}
}
throw const FormatException('closed');
} finally {
s.destroy();
}
}
/// DNS over HTTPS (RFC 8484, POST application/dns-message). DoH 서버 이름은 직접 지정 서버로 찾는다
Future<DnsAnswer> _doh(DnsConfig c, String name, int type) async {
final url = Uri.parse(c.dohUrl.trim().replaceAll('{?dns}', ''));
final client = dohClient?.call() ?? HttpClient();
client
..findProxy = ((_) => 'DIRECT')
..connectionTimeout = timeout;
if (dohClient == null && InternetAddress.tryParse(url.host) == null) {
final servers = c.servers;
client.connectionFactory = (uri, _, _) async {
final addrs = <InternetAddress>[];
if (servers.isEmpty) {
// 직접 지정 서버가 없으면 DoH 서버 이름만 기기 DNS 로 (그 뒤의 이름은 모두 DoH)
addrs.addAll(await InternetAddress.lookup(uri.host));
} else {
for (final s in servers) {
try {
addrs.addAll((await _udp(s, uri.host, 1)).addresses);
if (addrs.isNotEmpty) break;
} catch (_) {}
}
}
if (addrs.isEmpty) throw DnsException(trf('DoH 서버 이름을 찾을 수 없습니다: {0}', [uri.host]), unreachable: true);
final raw = await Socket.connect(addrs.first, uri.port, timeout: timeout);
return ConnectionTask.fromSocket(SecureSocket.secure(raw, host: uri.host), raw.destroy);
};
}
try {
final req = await client.postUrl(url);
req.headers
..contentType = ContentType('application', 'dns-message')
..set(HttpHeaders.acceptHeader, 'application/dns-message');
final q = buildDnsQuery(name, type, id: 0); // RFC 8484: id 0
req.contentLength = q.length;
req.add(q);
final res = await req.close().timeout(timeout * 2);
final body = BytesBuilder();
await for (final chunk in res.timeout(timeout * 2)) {
body.add(chunk);
}
if (res.statusCode != 200) {
throw DnsException(trf('DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다', [c.dohUrl, res.statusCode]),
unreachable: true, server: c.dohUrl);
}
return parseDnsResponse(body.toBytes());
} on DnsException {
rethrow;
} catch (e) {
throw DnsException(trf('DoH 서버 ({0}) 에 닿지 않습니다: {1}', [c.dohUrl, e]), unreachable: true, server: c.dohUrl);
} finally {
client.close(force: true);
}
}
}
/// 같은 집 안 (LAN) · 이 기기 이름: 정한 DNS 서버가 아니라 기기 DNS 로 푼다
/// (예: NAS 이름 ds1815, *.local, Tailscale 의 *.ts.net - 공용 DNS 는 이런 이름을 모른다)
bool isLocalName(String host) {
final h = host.toLowerCase().replaceAll(RegExp(r'\.$'), '');
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
if (InternetAddress.tryParse(h) != null) return false;
if (!h.contains('.')) return true; // 점 없는 이름 (NAS · PC 이름)
const suffixes = ['.local', '.lan', '.home', '.home.arpa', '.internal', '.intranet', '.corp', '.ts.net'];
return suffixes.any(h.endsWith);
}
/// 이 기기 · 같은 집 안 주소 (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10 (Tailscale), ::1, fc00::/7, fe80::/10)
bool isPrivateAddress(InternetAddress a) {
final b = a.rawAddress;
if (a.type == InternetAddressType.IPv4) {
return b[0] == 127 ||
b[0] == 10 ||
(b[0] == 172 && b[1] >= 16 && b[1] < 32) ||
(b[0] == 192 && b[1] == 168) ||
(b[0] == 169 && b[1] == 254) ||
(b[0] == 100 && b[1] >= 64 && b[1] < 128);
}
if (a.isLoopback) return true;
return (b[0] & 0xfe) == 0xfc || (b[0] == 0xfe && (b[1] & 0xc0) == 0x80);
}