353 lines
15 KiB
Dart
353 lines
15 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 주소는 호스트만 ("https://dns.nextdns.io/…", [hideUrlPaths]).
|
|
/// 설정 화면은 [dohUrl] 을 그대로 보인다
|
|
String get label => mode == 'doh' ? hideUrlPaths(dohUrl) : [primary, secondary].where((s) => s.trim().isNotEmpty).join(', ');
|
|
}
|
|
|
|
/// 글 안의 http(s) 주소에서 경로 · 질의를 가린다: "https://dns.nextdns.io/abc123" → "https://dns.nextdns.io/…".
|
|
/// NextDNS 처럼 DoH 주소의 경로에 개인 ID 가 있어 작업 기록 · app.log · 안내에 남기지 않는다 (관리자 결정 10/17)
|
|
String hideUrlPaths(String s) =>
|
|
s.replaceAllMapped(RegExp(r'(https?://[^\s/?#)\]]+)[/?#][^\s)\]]*', caseSensitive: false), (m) => m[0]!.length == m[1]!.length + 1 ? m[0]! : '${m[1]}/…');
|
|
|
|
/// 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 {
|
|
// 이름 자체의 문제 (너무 긴 이름) 는 서버 탓이 아니다 - 묻기 전에 보고 그대로 알린다.
|
|
// 예전에는 서버마다 실패한 끝에 "DNS 서버 (…) 에 닿지 않습니다: 주소 이름이 너무 깁니다: <이름>" (닿지 않음) 으로 바뀌어
|
|
// DNS 안내가 뜨고 작업 기록에 연 사이트 이름이 남았다 (15 지적 · 관리자 10/17)
|
|
buildDnsQuery(name, type);
|
|
if (c.mode == 'doh') return (await _doh(c, name, type), c.label);
|
|
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.label, res.statusCode]),
|
|
unreachable: true, server: c.label);
|
|
}
|
|
return parseDnsResponse(body.toBytes());
|
|
} on DnsException {
|
|
rethrow;
|
|
} catch (e) {
|
|
throw DnsException(hideUrlPaths(trf('DoH 서버 ({0}) 에 닿지 않습니다: {1}', [c.label, e])), unreachable: true, server: c.label);
|
|
} 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);
|
|
}
|