import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../l10n/tr.dart'; /// Tor 화면의 연결 상태 enum TorPhase { off, starting, ready, failed } class TorStatus { final TorPhase phase; /// 연결 (bootstrap) 진행률 0 ~ 100 final int percent; /// 지금 하는 일 · 실패 이유 final String message; const TorStatus(this.phase, {this.percent = 0, this.message = ''}); static const off = TorStatus(TorPhase.off); bool get ready => phase == TorPhase.ready; } /// 새는 것 막기 (사용자 결정 10/10): Tor 화면의 웹 뷰는 이 셋이 모두 맞을 때만 만든다 (그 전에는 아무것도 읽지 않는다). /// - Tor 연결이 100% 끝났다 /// - 웹 뷰가 Tor 로 나가도록 프록시를 걸었다 (Android 는 앱 전체 웹 뷰 프록시를 걸고 끝날 때까지 기다림) /// - Tor 화면 전용 프로필 (쿠키 · 기록 · 캐시를 일반 브라우저와 따로) 을 준비했다 bool torViewMayLoad({required TorStatus status, required bool proxyApplied, required bool profileReady}) => status.ready && proxyApplied && profileReady; /// tor 의 알림 줄에서 연결 진행 ("Bootstrapped 45% (loading_descriptors): Loading relay descriptors") 을 읽는다 (int, String)? parseBootstrap(String line) { final m = RegExp(r'Bootstrapped (\d+)%(?: \(([^)]*)\))?:?\s*(.*)$').firstMatch(line); if (m == null) return null; return (int.parse(m.group(1)!), m.group(3)?.trim() ?? ''); } /// tor 가 연 SOCKS 포트 ("Opened Socks listener connection (ready) on 127.0.0.1:9150") int? parseSocksPort(String line) { final m = RegExp(r'Opened Socks listener.* on 127\.0\.0\.1:(\d+)').firstMatch(line); return m == null ? null : int.parse(m.group(1)!); } /// tor 설정 (torrc). 경로는 앱 데이터 폴더 아래 (사용자 기본 torrc 를 읽지 않게 모두 적는다) String buildTorrc({ required String dataDir, required String controlPortFile, required int ownerPid, String? geoip, String? geoip6, }) { final lines = [ 'DataDirectory $dataDir', // 127.0.0.1 의 빈 포트 (다른 프로그램 · 공식 Tor Browser 와 겹치지 않게) 'SocksPort 127.0.0.1:auto', 'ControlPort 127.0.0.1:auto', 'CookieAuthentication 1', 'ControlPortWriteToFile $controlPortFile', 'ClientOnly 1', 'AvoidDiskWrites 1', 'Log notice stdout', // 앱이 끝나면 (비정상 종료 포함) tor 도 끝난다 '__OwningControllerProcess $ownerPid', if (geoip != null) 'GeoIPFile $geoip', if (geoip6 != null) 'GeoIPv6File $geoip6', ]; return '${lines.join('\n')}\n'; } /// Tor 제어 포트에 명령 보내기 (쿠키 인증). [commands] 의 답 (250 줄들) 을 돌려준다 Future> torControl(int port, List cookie, List commands, {Duration timeout = const Duration(seconds: 10)}) async { final s = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: timeout); final lines = StreamIterator(s.cast>().transform(utf8.decoder).transform(const LineSplitter())); Future> reply() async { final out = []; while (await lines.moveNext().timeout(timeout)) { final l = lines.current; out.add(l); // "250 OK" 처럼 넷째 글자가 빈칸이면 답의 끝 if (l.length >= 4 && l[3] == ' ') break; } if (out.isEmpty || !out.last.startsWith('250')) { throw SocketException(trf('Tor 제어 명령 실패: {0}', [out.join(' / ')])); } return out; } try { final hex = cookie.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); s.write('AUTHENTICATE $hex\r\n'); await reply(); final all = []; for (final c in commands) { s.write('$c\r\n'); all.addAll(await reply()); } s.write('QUIT\r\n'); return all; } finally { await lines.cancel(); s.destroy(); } }