import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import '../l10n/tr.dart'; import 'dns_resolver.dart'; /// 소켓에서 필요한 만큼 읽고 (머리 · SOCKS 답), 남은 것은 그대로 흘려보내기 위한 그릇. /// 소켓은 한 번만 들을 수 있으므로 처음부터 이것이 듣고, 다 읽은 뒤에는 [rest] 로 넘긴다. class SocketReader { final Socket socket; final _buf = BytesBuilder(copy: false); late final StreamSubscription _sub; Completer? _more; bool _closed = false; Object? _error; StreamController>? _rest; SocketReader(this.socket) { _sub = socket.listen(_onData, onError: _onError, onDone: _onDone, cancelOnError: true); } void _onData(Uint8List d) { final r = _rest; if (r != null) { r.add(d); return; } _buf.add(d); _wake(); } void _onError(Object e) { _error = e; _closed = true; _rest?.addError(e); _rest?.close(); _wake(); } void _onDone() { _closed = true; _rest?.close(); _wake(); } void _wake() { final c = _more; _more = null; c?.complete(); } Future _wait() { if (_closed) throw _error ?? SocketException(tr('연결이 끊겼습니다')); return (_more ??= Completer()).future; } /// 정확히 [n] 바이트 Future read(int n) async { while (_buf.length < n) { await _wait(); } final all = _buf.takeBytes(); if (all.length > n) _buf.add(Uint8List.sublistView(all, n)); return Uint8List.sublistView(all, 0, n); } /// HTTP 머리 (빈 줄까지, 최대 [max] 바이트) Future readHead({int max = 64 * 1024}) async { while (true) { final b = _buf.toBytes(); for (var i = 3; i < b.length; i++) { if (b[i] == 10 && b[i - 1] == 13 && b[i - 2] == 10 && b[i - 3] == 13) { _buf.clear(); if (b.length > i + 1) _buf.add(Uint8List.sublistView(b, i + 1)); return Uint8List.sublistView(b, 0, i + 1); } } if (b.length > max) throw FormatException(tr('요청 머리가 너무 깁니다')); await _wait(); } } /// 아직 읽지 않은 것 + 앞으로 오는 것 (받는 쪽이 느리면 소켓 읽기를 멈춘다) Stream> rest() { final c = _rest = StreamController>( onPause: _sub.pause, onResume: _sub.resume, onCancel: () => _sub.cancel(), ); if (_buf.isNotEmpty) c.add(_buf.takeBytes()); if (_closed) { if (_error != null) c.addError(_error!); c.close(); } return c.stream; } } /// 위쪽 (실제 서버 쪽) 연결을 만드는 방법 abstract class ProxyConnector { /// [host]:[port] 로 가는 연결. 읽기는 [SocketReader] 로 (SOCKS 는 답을 먼저 읽는다) Future connect(String host, int port); } /// 정한 DNS 로 이름을 풀어 직접 잇는다 (DNS 강제). 같은 집 안 이름 · 주소는 기기 DNS 로 (LAN · NAS · Tailscale) class DnsConnector implements ProxyConnector { final DnsResolver resolver; final Duration timeout; DnsConnector(this.resolver, {this.timeout = const Duration(seconds: 15)}); @override Future connect(String host, int port) async { final ip = InternetAddress.tryParse(host); if (ip != null) return SocketReader(await Socket.connect(ip, port, timeout: timeout)); if (!resolver.config().forced || isLocalName(host)) { return SocketReader(await Socket.connect(host, port, timeout: timeout)); } final addrs = await resolver.lookup(host); Object? last; // IPv4 를 먼저 (IPv6 가 없는 망이 많다) for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) { try { return SocketReader(await Socket.connect(a, port, timeout: timeout)); } catch (e) { last = e; } } throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host])); } } /// Tor 의 SOCKS5 로 잇는다. 이름은 늘 Tor 에 넘겨 Tor 출구에서 푼다 (socks5h - 이 기기에서 DNS 를 묻지 않는다) class TorConnector implements ProxyConnector { /// 지금 Tor SOCKS 포트 (Tor 가 아직 준비되지 않았으면 null → 연결하지 않는다) final int? Function() socksPort; final Duration timeout; TorConnector(this.socksPort, {this.timeout = const Duration(seconds: 60)}); static const _replies = { 1: '일반 오류', 2: '규칙상 허용되지 않음', 3: '망에 닿을 수 없음', 4: '서버에 닿을 수 없음', 5: '연결 거부', 6: 'TTL 만료', 7: '지원하지 않는 명령', 8: '지원하지 않는 주소 형식', }; @override Future connect(String host, int port) async { final sp = socksPort(); if (sp == null) throw SocketException(tr('Tor 가 아직 연결되지 않았습니다')); final s = await Socket.connect(InternetAddress.loopbackIPv4, sp, timeout: const Duration(seconds: 5)); final r = SocketReader(s); try { s.add([5, 1, 0]); // 인증 없음 final hello = await r.read(2).timeout(timeout); if (hello[0] != 5 || hello[1] != 0) throw SocketException(tr('Tor SOCKS 가 연결을 받지 않습니다')); final ip = InternetAddress.tryParse(host); final req = BytesBuilder()..add([5, 1, 0]); if (ip != null && ip.type == InternetAddressType.IPv4) { req ..addByte(1) ..add(ip.rawAddress); } else if (ip != null && ip.type == InternetAddressType.IPv6) { req ..addByte(4) ..add(ip.rawAddress); } else { final name = utf8.encode(host); if (name.length > 255) throw SocketException(trf('주소 이름이 너무 깁니다: {0}', [host])); req ..addByte(3) // 이름 그대로 (Tor 가 푼다) ..addByte(name.length) ..add(name); } req.add([port >> 8, port & 0xff]); s.add(req.toBytes()); final head = await r.read(4).timeout(timeout); if (head[1] != 0) { throw SocketException(trf('Tor 가 {0}:{1} 에 연결하지 못했습니다 ({2})', [host, port, tr(_replies[head[1]] ?? '${head[1]}')])); } final skip = switch (head[3]) { 1 => 4, 4 => 16, 3 => (await r.read(1))[0], _ => 0 }; await r.read(skip + 2); return r; } catch (_) { s.destroy(); rethrow; } } } /// 실행마다 고르는 루프백 주소 127.x.y.z (같은 기기의 다른 앱이 찾기 어렵게 - 인증 없는 문에 쓴다: Android 웹 뷰 · Chrome 엔진) InternetAddress randomLoopback() { final r = math.Random.secure(); return InternetAddress('127.${1 + r.nextInt(254)}.${1 + r.nextInt(254)}.${2 + r.nextInt(253)}'); } /// 앱 안 프록시의 인증 (관리자 결정 10/11): 같은 기기의 다른 앱 · 프로그램이 이 프록시 (DNS 강제 · Tor 다리) 를 쓰지 못하게 /// 앱을 켤 때마다 새로 만드는 무작위 비밀값으로 Basic 프록시 인증 (Proxy-Authorization) 을 요구한다. 어디에도 저장하지 않는다. class ProxyAuth { final String user; final String password; ProxyAuth(this.user, this.password); /// 이번 실행의 비밀값 static final instance = ProxyAuth('jj', _random()); static String _random() { final r = math.Random.secure(); return base64Url.encode(List.generate(24, (_) => r.nextInt(256))).replaceAll('=', ''); } /// Proxy-Authorization 머리 값 String get header => 'Basic ${base64.encode(utf8.encode('$user:$password'))}'; /// http://user:pass@127.0.0.1:port (yt-dlp 등) String url(int port) => 'http://$user:$password@127.0.0.1:$port'; /// 받은 머리 값이 맞는지 (길이가 같으면 끝까지 비교 - 앞부분이 맞는 정도로 시간이 달라지지 않게) bool accepts(String? value) { if (value == null) return false; final a = utf8.encode(value.trim()); final b = utf8.encode(header); if (a.length != b.length) return false; var diff = 0; for (var i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff == 0; } } /// 거절 기록에 남길 요청: "방식 주소:포트" 만 - 경로 · 질의 (? 뒤) 는 남기지 않는다 (관리자 결정 10/17, 개인정보) String requestLineForLog(String method, String target) { if (method == 'CONNECT') return '$method ${target.split('/').first}'; final u = Uri.tryParse(target); if (u == null || u.host.isEmpty) return '$method ?'; return '$method ${u.host.contains(':') ? '[${u.host}]' : u.host}:${u.port}'; } /// 앱 안 HTTP 프록시 (127.0.0.1 의 빈 포트). CONNECT (https) 와 http 요청을 받아 [connector] 로 잇는다. /// - DNS 강제: [DnsConnector] - 웹 브라우저 · yt-dlp · aria2 · 앱의 HTTP 가 이리로 /// - Tor 화면: [TorConnector] - 웹 뷰가 이리로 (이름 풀기까지 Tor 안에서) /// 연결하지 못하면 직접 잇지 않고 502 로 답한다 ([onError] 로 알린다). class LocalProxy { final ProxyConnector connector; /// 위쪽 연결 실패 (호스트, 오류). DNS 서버에 닿지 않음 등을 화면에 알리려고 void Function(String host, Object error)? onError; /// 시험용: 받은 요청 (메서드, 호스트, 포트) void Function(String method, String host, int port)? onRequest; /// 요구하는 인증 (null 이면 묻지 않음 - 시험용). 맞지 않으면 407 final ProxyAuth? auth; /// 인증이 없거나 틀려 거절함 ("방식 주소:포트" ([requestLineForLog]), 비밀값을 보냈는지) - 작업 기록 (비밀값 자체는 남기지 않는다) void Function(String requestLine, bool hadCredentials)? onRejected; LocalProxy(this.connector, {this.onError, this.auth}); ServerSocket? _server; final _open = {}; int get port => _server?.port ?? 0; /// 묶인 주소 (예: 127.0.0.1) String get host => _server?.address.address ?? '127.0.0.1'; bool get running => _server != null; /// 띄운다. [prefer] 포트가 비어 있으면 그 포트로 (Windows Tor 웹 뷰 환경은 만든 뒤 프록시 주소를 바꿀 수 없어 같은 포트로 다시 연다) /// [address]: 묶을 루프백 주소 (기본 127.0.0.1. Android 웹 뷰 문은 실행마다 고른 127.x.y.z - [WebProxy]) Future start({int? prefer, InternetAddress? address}) async { if (_server != null) return _server!.port; final at = address ?? InternetAddress.loopbackIPv4; // 루프백 밖으로는 열지 않는다 if (!at.isLoopback) throw ArgumentError.value(at, 'address', 'loopback only'); ServerSocket? s; if (prefer != null && prefer > 0) { try { s = await ServerSocket.bind(at, prefer); } catch (_) {} } s ??= await ServerSocket.bind(at, 0); _server = s; s.listen((c) => unawaited(_handle(c)), onError: (_) {}); return s.port; } Future close() async { final s = _server; _server = null; await s?.close(); for (final c in [..._open]) { c.destroy(); } _open.clear(); } Future _handle(Socket client) async { _open.add(client); final cr = SocketReader(client); SocketReader? up; try { final head = latin1.decode(await cr.readHead().timeout(const Duration(seconds: 30))); final lines = head.split('\r\n'); final parts = lines.first.split(' '); if (parts.length < 3) throw const FormatException('request line'); final method = parts[0].toUpperCase(); final target = parts[1]; // 인증: 이 앱이 아닌 것 (같은 기기의 다른 앱) 은 거절 final a = auth; if (a != null) { final given = lines .skip(1) .where((l) => l.toLowerCase().startsWith('proxy-authorization:')) .map((l) => l.substring(l.indexOf(':') + 1)) .firstOrNull; if (!a.accepts(given)) { onRejected?.call(requestLineForLog(method, target), given != null); client.add(latin1.encode('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="JJ_MKVMaker"\r\n' 'Content-Length: 0\r\nConnection: close\r\n\r\n')); await client.flush(); return; } } String host; int port; if (method == 'CONNECT') { final hp = _splitHostPort(target, 443); host = hp.$1; port = hp.$2; } else { final u = Uri.tryParse(target); if (u == null || !u.hasScheme || u.host.isEmpty) { client.add(latin1.encode('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')); await client.flush(); return; } host = u.host; port = u.hasPort ? u.port : (u.isScheme('https') ? 443 : 80); } onRequest?.call(method, host, port); try { up = await connector.connect(host, port); } catch (e) { onError?.call(host, e); final body = utf8.encode('

JJ_MKVMaker

${const HtmlEscape().convert('$e')}

'); client.add(latin1.encode('HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html; charset=utf-8\r\n' 'Content-Length: ${body.length}\r\nConnection: close\r\nX-JJ-Proxy-Error: 1\r\n\r\n')); client.add(body); await client.flush(); return; } final upSock = up.socket; _open.add(upSock); if (method == 'CONNECT') { client.add(latin1.encode('HTTP/1.1 200 Connection Established\r\n\r\n')); } else { // http: 요청 줄을 원래 서버용 (경로만) 으로, 한 연결에 요청 하나 (Connection: close) - 다음 요청은 새 연결 final u = Uri.parse(target); final path = '${u.path.isEmpty ? '/' : u.path}${u.hasQuery ? '?${u.query}' : ''}'; final out = StringBuffer('$method $path ${parts[2]}\r\n'); for (final l in lines.skip(1)) { if (l.isEmpty) continue; final name = l.split(':').first.trim().toLowerCase(); if (const ['proxy-connection', 'proxy-authorization', 'connection', 'keep-alive'].contains(name)) continue; out.write('$l\r\n'); } out.write('Connection: close\r\n\r\n'); upSock.add(latin1.encode(out.toString())); } // 양쪽으로 흘려보낸다 (한쪽이 끝나면 다른 쪽도 닫는다) final toUp = upSock.addStream(cr.rest()).then((_) => upSock.close(), onError: (_) => upSock.destroy()); final toClient = client.addStream(up.rest()).then((_) => client.close(), onError: (_) => client.destroy()); await Future.any([toClient, toUp.then((_) => toClient)]); } catch (_) { // 머리가 깨졌거나 한쪽이 끊김 } finally { client.destroy(); up?.socket.destroy(); _open.remove(client); if (up != null) _open.remove(up.socket); } } static (String, int) _splitHostPort(String s, int def) { if (s.startsWith('[')) { final end = s.indexOf(']'); final host = s.substring(1, end); final rest = s.substring(end + 1); return (host, rest.startsWith(':') ? int.parse(rest.substring(1)) : def); } final i = s.lastIndexOf(':'); if (i < 0) return (s, def); return (s.substring(0, i), int.parse(s.substring(i + 1))); } }