import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import '../core/dns_resolver.dart'; import '../core/local_proxy.dart'; import '../l10n/tr.dart'; /// DNS 서버에 닿지 않았을 때 화면에 알릴 것 class DnsFailure { final String server; final String reason; final DateTime at; const DnsFailure(this.server, this.reason, this.at); } /// DNS 강제 (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로"). /// 앱 안 HTTP 프록시 ([LocalProxy] + [DnsConnector]) 를 127.0.0.1 에 띄우고, Tor 밖으로 나가는 앱의 연결을 그리로 보낸다: /// - 앱의 HTTP (업데이트 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 번역 · 자막 검색): [JjHttpOverrides] 의 findProxy /// - 웹 브라우저: Windows Edge · Chrome 엔진의 --proxy-server, Android 는 웹 뷰 프록시 (WebProxy) /// - 다운로드: yt-dlp --proxy, aria2 --all-proxy (+ --async-dns-server) /// 이름은 프록시가 정한 DNS 로 푼다. DNS 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 [failure] 로 알린다. class NetGuard { NetGuard._(); static final instance = NetGuard._(); /// 지금 DNS 설정 (main 에서 환경 설정을 읽게 바꾼다) DnsConfig Function() config = () => const DnsConfig.system(); late final DnsResolver resolver = DnsResolver(config: () => config()); LocalProxy? _proxy; Future? _starting; /// DNS 서버에 닿지 않음 (화면에 알림 - [시스템 DNS 로 바꾸기]) final failure = ValueNotifier(null); /// 작업 기록 void Function(String)? log; /// 프록시 포트 (아직 안 띄웠으면 null) int? get port => _proxy?.running == true ? _proxy!.port : null; /// 지금 DNS 를 강제하는지 (설정이 직접 지정 · DoH 이고 프록시가 떠 있음) bool get forcing => config().forced && port != null; /// 설정이 강제면 프록시를 띄운다 (이미 떠 있으면 그대로). 설정을 바꾼 뒤에도 부른다 Future ensure() async { resolver.clearCache(); if (!config().forced) return; if (port != null) return; _starting ??= () async { // 이 앱만 쓰게 (같은 기기의 다른 앱은 407 - [ProxyAuth]) final p = LocalProxy(DnsConnector(resolver), onError: reportError, auth: ProxyAuth.instance) ..onRejected = (line, had) => log?.call(had ? trf('DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})', [line]) : trf('DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})', [line])); final n = await p.start(); _proxy = p; log?.call(trf('DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})', [n, config().label])); return n; }(); try { await _starting; } finally { _starting = null; } } DateTime? _lastAlert; void reportError(String host, Object e) { if (e is! DnsException || !e.unreachable) return; log?.call(trf('DNS 실패 ({0}): {1}', [host, e])); final now = DateTime.now(); // 같은 알림을 계속 띄우지 않게 (1분에 한 번) if (_lastAlert != null && now.difference(_lastAlert!) < const Duration(minutes: 1)) return; _lastAlert = now; failure.value = DnsFailure(e.server.isEmpty ? config().label : e.server, e.message, now); } /// 이 주소는 프록시를 거치지 않는다: IP 주소 (DNS 를 묻지 않음) · 같은 집 안 이름 (LAN · NAS · Tailscale) static bool isDirectHost(String host) => host.isEmpty || InternetAddress.tryParse(host) != null || isLocalName(host); /// Dart HttpClient 의 findProxy String findProxy(Uri u) { if (!config().forced || isDirectHost(u.host)) return 'DIRECT'; final p = port; // 강제인데 프록시가 아직 없으면 직접 잇지 않는다 (닫힌 포트로 보내 실패하게 - 몰래 기기 DNS 를 쓰지 않음) if (p == null) return 'PROXY 127.0.0.1:9'; final a = ProxyAuth.instance; return 'PROXY ${a.user}:${a.password}@127.0.0.1:$p'; } /// 웹 브라우저 (Edge · Chrome 엔진) 에 줄 프록시 주소. 강제가 아니면 null. /// 인증 (비밀값) 은 주소에 넣지 않고 브라우저가 물을 때 답한다 ([ProxyAuth]) String? get browserProxy => forcing ? 'http://127.0.0.1:$port' : null; // ───────── 207: Chrome 엔진 (CEF) 문 ───────── // Chrome 엔진은 프록시 인증 (407) 에 답하지 않는다 (CEF 149 windowless, 10/11 확인). 그래서 Chrome 엔진에만 인증 없는 문을 // 실행마다 고른 127.x.y.z 의 빈 포트에, Chrome 엔진 웹 뷰가 있는 동안만 연다 (관리자 결정 10/11). 엔진의 프록시 주소는 // 시작할 때 한 번 정해지므로 이번 실행 동안 같은 주소 · 포트로 다시 연다. LocalProxy? _cefGate; InternetAddress? _cefAddress; int? _cefPort; int _cefViews = 0; /// 지금 열린 Chrome 엔진 문 (시험 · 기록) String? get cefGateUrl => _cefGate?.running == true ? 'http://${_cefGate!.host}:${_cefGate!.port}' : null; /// Chrome 엔진 웹 뷰가 생김: 문을 열고 그 주소 (DNS 강제가 아니면 null - 프록시 없음) Future openCefGate() async { if (!config().forced) return null; _cefViews++; final g = _cefGate ??= LocalProxy(DnsConnector(resolver), onError: reportError); if (!g.running) { final at = _cefAddress ??= randomLoopback(); try { await g.start(prefer: _cefPort, address: at); } catch (_) { // 127.x.y.z 에 묶을 수 없는 PC: 127.0.0.1 (처음 열 때만 - 엔진이 이미 다른 주소를 쓰고 있으면 바꾸지 않는다) if (_cefPort != null) rethrow; _cefAddress = InternetAddress.loopbackIPv4; await g.start(address: _cefAddress); } _cefPort ??= g.port; log?.call(trf('Chrome 엔진 프록시 문 열림: {0}:{1}', [g.host, g.port])); } return 'http://${g.host}:${g.port}'; } /// Chrome 엔진 웹 뷰가 없어짐: 마지막이면 문을 닫는다 (그 사이 엔진이 나가려 하면 실패 - 직접 나가지 않음) Future closeCefGate() async { if (_cefViews > 0) _cefViews--; if (_cefViews > 0) return; final g = _cefGate; _cefGate = null; if (g == null || !g.running) return; final at = '${g.host}:${g.port}'; await g.close(); log?.call(trf('Chrome 엔진 프록시 문 닫힘: {0}', [at])); } /// yt-dlp 인수 List ytDlpArgs() => forcing ? ['--proxy', ProxyAuth.instance.url(port!)] : const []; /// aria2 인수: http · https · ftp 와 HTTP 트래커는 프록시로, UDP 트래커 · DHT 의 이름은 정한 서버로 ([asyncDns] 기능이 있는 aria2 만) List aria2Args({required bool asyncDns}) { if (!forcing) return const []; final c = config(); final servers = c.mode == 'custom' ? c.servers.map((a) => a.address).toList() : const []; return [ '--all-proxy=http://127.0.0.1:$port', '--all-proxy-user=${ProxyAuth.instance.user}', '--all-proxy-passwd=${ProxyAuth.instance.password}', if (asyncDns && servers.isNotEmpty) ...['--async-dns=true', '--async-dns-server=${servers.join(',')}'], ]; } } /// 앱의 모든 HttpClient 가 [NetGuard.findProxy] 를 따르게 (main 에서 HttpOverrides.global 로) class JjHttpOverrides extends HttpOverrides { @override HttpClient createHttpClient(SecurityContext? context) => super.createHttpClient(context)..findProxy = NetGuard.instance.findProxy; }