396 lines
15 KiB
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)));
|
|
}
|
|
}
|