105 lines
3.8 KiB
Dart
105 lines
3.8 KiB
Dart
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<List<String>> torControl(int port, List<int> cookie, List<String> commands,
|
|
{Duration timeout = const Duration(seconds: 10)}) async {
|
|
final s = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: timeout);
|
|
final lines = StreamIterator(s.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()));
|
|
Future<List<String>> reply() async {
|
|
final out = <String>[];
|
|
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 = <String>[];
|
|
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();
|
|
}
|
|
}
|