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 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 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 = []; 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 = , DateTime)>{}; final _pending = >>{}; void clearCache() => _cache.clear(); /// [host] 의 주소들 (IPv4 먼저) Future> 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, int)> _resolve(DnsConfig c, String name) async { final results = await Future.wait([_ask(c, name, 1), _ask(c, name, 28)].map((f) => f.then((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().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; } } throw DnsException(trf('DNS 서버 ({0}) 에 닿지 않습니다: {1}', [c.label, last]), unreachable: true, server: c.label); } Future _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(); 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 _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 _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 = []; 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); }