import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../core/local_proxy.dart'; import '../core/tor_control.dart'; import '../l10n/tr.dart'; import 'tor_bundle.dart'; /// Tor 실행 (Tor 화면이 열려 있는 동안). tor 를 자식 프로세스로 띄우고 (SOCKS · 제어 포트는 127.0.0.1 의 빈 포트), /// 웹 뷰가 쓸 HTTP 프록시 다리 ([LocalProxy] + [TorConnector]) 를 연다. 다리는 이름을 Tor 에 넘긴다 (이 기기의 DNS 를 쓰지 않음). class TorService { TorService._(); static final instance = TorService._(); /// 앱 데이터 폴더 (main 에서 정함). tor 의 DataDirectory 는 그 아래 tor_data static String dataDir = Directory.systemTemp.path; final status = ValueNotifier(TorStatus.off); /// 작업 기록 void Function(String)? log; /// 시험용: 실행 파일 · 인수를 바꾼다 @visibleForTesting Future Function() executable = TorBundle.executable; Process? _proc; int? _socksPort; int? _controlPort; LocalProxy? _bridge; Timer? _watchdog; DateTime _lastProgress = DateTime.now(); final _recent = []; Future? _starting; /// 지금 Tor SOCKS 포트 (준비되지 않았으면 null) int? get socksPort => status.value.ready ? _socksPort : null; /// 웹 뷰가 쓸 HTTP 프록시 (127.0.0.1:포트). 앱이 켜져 있는 동안 같은 포트 (Windows 웹 뷰 환경은 만든 뒤 바꿀 수 없다) Future bridgePort() async { final b = _bridge ??= LocalProxy(TorConnector(() => socksPort)); return b.start(); } bool get running => _proc != null; /// 켜기 (이미 켜져 있거나 켜는 중이면 그것을 기다린다) Future start() { if (status.value.ready && _proc != null) return Future.value(); return _starting ??= _start().whenComplete(() => _starting = null); } Future _start() async { final exe = await executable(); if (exe == null) { _set(TorStatus(TorPhase.failed, message: tr('Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)'))); return; } await bridgePort(); final data = Directory(p.join(dataDir, 'tor_data'))..createSync(recursive: true); final portFile = File(p.join(data.path, 'control-port.txt')); if (portFile.existsSync()) portFile.deleteSync(); final torrc = File(p.join(data.path, 'torrc')); torrc.writeAsStringSync(buildTorrc( dataDir: data.path, controlPortFile: portFile.path, ownerPid: pid, geoip: Platform.isWindows ? TorBundle.geoip('geoip') : null, geoip6: Platform.isWindows ? TorBundle.geoip('geoip6') : null, )); final defaults = File(p.join(data.path, 'torrc-defaults'))..writeAsStringSync(''); _recent.clear(); _socksPort = null; _controlPort = null; _set(TorStatus(TorPhase.starting, message: tr('Tor 를 시작하는 중'))); final Process proc; try { proc = await Process.start(exe, ['-f', torrc.path, '--defaults-torrc', defaults.path], workingDirectory: data.path, environment: Platform.isAndroid ? {'HOME': data.path} : null); } catch (e) { _set(TorStatus(TorPhase.failed, message: trf('Tor 를 실행할 수 없습니다: {0}', [e]))); return; } _proc = proc; log?.call(trf('Tor 시작 ({0})', [exe])); _lastProgress = DateTime.now(); void onLine(String line) { if (_recent.length > 20) _recent.removeAt(0); _recent.add(line); final port = parseSocksPort(line); if (port != null) _socksPort = port; final b = parseBootstrap(line); if (b != null) { _lastProgress = DateTime.now(); if (b.$1 >= 100) { unawaited(_becomeReady(portFile)); } else { _set(TorStatus(TorPhase.starting, percent: b.$1, message: b.$2)); } } } proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(onLine); proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(onLine); unawaited(proc.exitCode.then((code) { if (!identical(_proc, proc)) return; _proc = null; _watchdog?.cancel(); if (status.value.phase != TorPhase.off) { final why = _recent.lastWhere((l) => l.contains('[err]') || l.contains('[warn]'), orElse: () => ''); _set(TorStatus(TorPhase.failed, message: trf('Tor 가 끝났습니다 (코드 {0}) {1}', [code, why]))); log?.call(trf('Tor 끝남 (코드 {0}): {1}', [code, _recent.join(' | ')])); } })); // 2분 동안 진행이 없으면 실패로 (망이 막혀 있는 등) _watchdog?.cancel(); _watchdog = Timer.periodic(const Duration(seconds: 5), (_) { if (status.value.phase != TorPhase.starting) return; if (DateTime.now().difference(_lastProgress) > const Duration(minutes: 2)) { final why = _recent.lastWhere((l) => l.contains('[warn]'), orElse: () => ''); unawaited(stop(keepStatus: true)); _set(TorStatus(TorPhase.failed, percent: status.value.percent, message: trf('Tor 연결 시간 초과 {0}', [why]))); } }); } Future _becomeReady(File portFile) async { // 제어 포트 (새 경로에 씀) 와 SOCKS 포트 확인 for (var i = 0; i < 50 && !portFile.existsSync(); i++) { await Future.delayed(const Duration(milliseconds: 100)); } try { final m = RegExp(r'PORT=127\.0\.0\.1:(\d+)').firstMatch(portFile.readAsStringSync()); _controlPort = m == null ? null : int.parse(m.group(1)!); } catch (_) {} if (_socksPort == null && _controlPort != null) { try { final r = await _control(['GETINFO net/listeners/socks']); final m = RegExp(r'127\.0\.0\.1:(\d+)').firstMatch(r.join(' ')); if (m != null) _socksPort = int.parse(m.group(1)!); } catch (_) {} } if (_socksPort == null) { _set(TorStatus(TorPhase.failed, message: tr('Tor SOCKS 포트를 찾지 못했습니다'))); await stop(keepStatus: true); return; } _set(TorStatus(TorPhase.ready, percent: 100, message: tr('연결됨'))); log?.call(trf('Tor 연결됨 (SOCKS 127.0.0.1:{0})', [_socksPort])); } Future> _control(List commands) async { final port = _controlPort; if (port == null) throw SocketException(tr('Tor 제어 포트가 없습니다')); final cookie = await File(p.join(dataDir, 'tor_data', 'control_auth_cookie')).readAsBytes(); return torControl(port, cookie, commands); } /// [새 경로]: 앞으로의 연결은 새 Tor 회로로 (NEWNYM) Future newIdentity() async { try { await _control(['SIGNAL NEWNYM']); log?.call(tr('Tor 새 경로')); } catch (e) { // 제어 포트를 쓸 수 없으면 다시 시작 log?.call(trf('Tor 새 경로 실패 ({0}) - 다시 시작합니다', [e])); await stop(); await start(); } } /// 끄기 (Tor 화면을 닫을 때 · 앱이 끝날 때 · 제거할 때) Future stop({bool keepStatus = false}) async { _watchdog?.cancel(); final proc = _proc; _proc = null; _socksPort = null; if (!keepStatus) _set(TorStatus.off); if (proc != null) { try { await _control(['SIGNAL SHUTDOWN']).timeout(const Duration(seconds: 2)); } catch (_) {} proc.kill(); log?.call(tr('Tor 끔')); } } void _set(TorStatus s) => status.value = s; }