JJ_MKVMaker/lib/core/local_proxy.dart

396 lines
15 KiB
Dart

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<Uint8List> _sub;
Completer<void>? _more;
bool _closed = false;
Object? _error;
StreamController<List<int>>? _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<void> _wait() {
if (_closed) throw _error ?? SocketException(tr('연결이 끊겼습니다'));
return (_more ??= Completer<void>()).future;
}
/// 정확히 [n] 바이트
Future<Uint8List> 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<Uint8List> 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<List<int>> rest() {
final c = _rest = StreamController<List<int>>(
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<SocketReader> 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<SocketReader> 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<SocketReader> 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;
}
}
/// 앱 안 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;
/// 인증이 없거나 틀려 거절함 (요청 줄, 비밀값을 보냈는지) - 작업 기록 (비밀값 자체는 남기지 않는다)
void Function(String requestLine, bool hadCredentials)? onRejected;
LocalProxy(this.connector, {this.onError, this.auth});
ServerSocket? _server;
final _open = <Socket>{};
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<int> 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<void> close() async {
final s = _server;
_server = null;
await s?.close();
for (final c in [..._open]) {
c.destroy();
}
_open.clear();
}
Future<void> _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(lines.first, 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('<html><body><h3>JJ_MKVMaker</h3><p>${const HtmlEscape().convert('$e')}</p></body></html>');
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)));
}
}