JJ_MKVMaker/lib/app/net_guard.dart

213 lines
10 KiB
Dart

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();
// 249: 정한 서버가 다시 답하면 "닿지 않음" 상태를 지운다
late final DnsResolver resolver = DnsResolver(config: () => config())..onAnswer = (_, _) => _answered();
LocalProxy? _proxy;
Future<int>? _starting;
/// DNS 서버에 닿지 않음 (화면에 알림 - [시스템 DNS 로 바꾸기]).
/// 249: 서버가 다시 답하거나 DNS 설정을 바꿀 때까지 남는다 - 아래 안내를 놓쳐도 환경 설정 > 네트워크 · DNS 와 작업 현황에 보인다.
final failure = ValueNotifier<DnsFailure?>(null);
/// 248: 아래 알림을 띄우지 않을 화면이 열려 있는 수 (브라우저의 DNS 덮개 · 환경 설정의 DNS 칸 - 같은 안내 · 버튼을 이미 보인다)
final noticeBlockers = ValueNotifier<int>(0);
/// 작업 기록
void Function(String)? log;
/// 프록시 포트 (아직 안 띄웠으면 null)
int? get port => _proxy?.running == true ? _proxy!.port : null;
/// 지금 DNS 를 강제하는지 (설정이 직접 지정 · DoH 이고 프록시가 떠 있음)
bool get forcing => config().forced && port != null;
/// 설정이 강제면 프록시를 띄운다 (이미 떠 있으면 그대로). 설정을 바꾼 뒤에도 부른다
Future<void> ensure() async {
resolver.clearCache();
failure.value = null; // 설정이 바뀌었다 - 새 설정으로 다시 본다
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? _lastLog;
/// 247: 프록시가 연결하지 못한 이름과 그 까닭 (앱의 HTTP 오류를 사용자 글로 바꿀 때)
final _lastError = <String, (Object, DateTime)>{};
void reportError(String host, Object e) {
if (_lastError.length > 200) _lastError.clear();
_lastError[host.toLowerCase()] = (e, DateTime.now());
if (e is! DnsException || !e.unreachable) return;
final now = DateTime.now();
// 작업 기록은 1분에 한 번 (같은 실패가 줄줄이 나지 않게)
if (_lastLog == null || now.difference(_lastLog!) >= const Duration(minutes: 1)) {
_lastLog = now;
// 연 사이트 이름은 작업 기록 (app.log) 에 남기지 않는다 (개인정보) - 닿지 않은 DNS 서버와 까닭만
log?.call(trf('DNS 실패: {0}', [e]));
}
// 248 · 249: 안내는 "닿지 않음" 이 처음 생길 때 한 번 (다시 답한 뒤 또 실패하면 다시). 상태는 [failure] 에 남는다
if (failure.value != null) return;
failure.value = DnsFailure(e.server.isEmpty ? config().label : e.server, e.message, now);
}
void _answered() {
if (failure.value != null) failure.value = null;
}
/// 250: [host] 를 [since] 뒤에 DNS 서버에 닿지 않아 잇지 못했으면 그 서버 (아니면 null).
/// 웹 브라우저가 엔진의 기본 오류 화면 ("net::ERR_TUNNEL_CONNECTION_FAILED") 대신 이유와 할 일을 보이게
String? dnsUnreachableFor(String host, DateTime since) {
// 그 뒤 서버가 다시 답했으면 (failure 가 지워짐) 지난 실패로 막지 않는다
if (failure.value == null) return null;
final last = _lastError[host.toLowerCase()];
if (last == null || last.$2.isBefore(since)) return null;
final e = last.$1;
if (e is DnsException && e.unreachable) return e.server.isEmpty ? config().label : e.server;
return null;
}
/// 247: 앱의 HTTP 요청이 DNS 강제 프록시에서 실패했으면 그 까닭을 사용자 글로 ("DNS 서버 (…) 에 닿지 않아 …").
/// 프록시 실패가 아니면 null (부르는 쪽이 원래 글을 쓴다). [what] 은 무엇을 못 했는지 ("새 버전을 확인하지 못했습니다").
String? explain(Object e, String what) {
if (e is! HttpException || !'$e'.contains('Proxy failed to establish tunnel')) return null;
final host = (e.uri?.host ?? '').toLowerCase();
final last = _lastError[host];
final recent = last != null && DateTime.now().difference(last.$2) < const Duration(minutes: 2);
final why = recent ? last.$1 : null;
if (why is DnsException && why.unreachable) {
return trf('DNS 서버 ({0}) 에 닿지 않아 {1}', [why.server.isEmpty ? config().label : why.server, what]);
}
if (why is DnsException) return trf('DNS 서버가 {0} 의 주소를 찾지 못해 {1}', [host, what]);
return trf('{0} 에 연결하지 못해 {1} (DNS 강제 프록시)', [host.isEmpty ? '?' : host, what]);
}
/// 이 주소는 프록시를 거치지 않는다: 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<String?> 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<void> 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<String> ytDlpArgs() => forcing ? ['--proxy', ProxyAuth.instance.url(port!)] : const [];
/// aria2 인수: http · https · ftp 와 HTTP 트래커는 프록시로, UDP 트래커 · DHT 의 이름은 정한 서버로 ([asyncDns] 기능이 있는 aria2 만)
List<String> aria2Args({required bool asyncDns}) {
if (!forcing) return const [];
final c = config();
final servers = c.mode == 'custom' ? c.servers.map((a) => a.address).toList() : const <String>[];
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;
}