194 lines
7.3 KiB
Dart
194 lines
7.3 KiB
Dart
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>(TorStatus.off);
|
|
|
|
/// 작업 기록
|
|
void Function(String)? log;
|
|
|
|
/// 시험용: 실행 파일 · 인수를 바꾼다
|
|
@visibleForTesting
|
|
Future<String?> Function() executable = TorBundle.executable;
|
|
|
|
Process? _proc;
|
|
int? _socksPort;
|
|
int? _controlPort;
|
|
LocalProxy? _bridge;
|
|
Timer? _watchdog;
|
|
DateTime _lastProgress = DateTime.now();
|
|
final _recent = <String>[];
|
|
Future<void>? _starting;
|
|
|
|
/// 지금 Tor SOCKS 포트 (준비되지 않았으면 null)
|
|
int? get socksPort => status.value.ready ? _socksPort : null;
|
|
|
|
/// 웹 뷰가 쓸 HTTP 프록시 (127.0.0.1:포트). 앱이 켜져 있는 동안 같은 포트 (Windows 웹 뷰 환경은 만든 뒤 바꿀 수 없다)
|
|
Future<int> bridgePort() async {
|
|
final b = _bridge ??= LocalProxy(TorConnector(() => socksPort));
|
|
return b.start();
|
|
}
|
|
|
|
bool get running => _proc != null;
|
|
|
|
/// 켜기 (이미 켜져 있거나 켜는 중이면 그것을 기다린다)
|
|
Future<void> start() {
|
|
if (status.value.ready && _proc != null) return Future.value();
|
|
return _starting ??= _start().whenComplete(() => _starting = null);
|
|
}
|
|
|
|
Future<void> _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<void> _becomeReady(File portFile) async {
|
|
// 제어 포트 (새 경로에 씀) 와 SOCKS 포트 확인
|
|
for (var i = 0; i < 50 && !portFile.existsSync(); i++) {
|
|
await Future<void>.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<List<String>> _control(List<String> 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<void> 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<void> 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;
|
|
}
|