JJ_MKVMaker/test/dns_proxy_test.dart

396 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/net_guard.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/dns_resolver.dart';
import 'package:jj_mkvmaker/core/local_proxy.dart';
import 'support/fake_dns.dart';
/// 정한 DNS 서버로 이름을 푸는지 · 앱 안 프록시가 그 이름으로 잇는지 · 같은 집 안 이름은 기기 DNS 로 · Tor 다리는 이름을 Tor 에 넘기는지
void main() {
DnsConfig custom(String primary, [String secondary = '']) =>
DnsConfig(mode: 'custom', primary: primary, secondary: secondary);
group('DNS 풀기 (가짜 서버)', () {
late FakeDns dns;
setUp(() async {
dns = FakeDns({'example.test': '10.1.2.3', 'up.test': '127.0.0.1'});
await dns.start();
});
tearDown(() => dns.close());
test('정한 서버에 묻고 답을 TTL 동안 기억한다', () async {
final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port);
String? server;
r.onAnswer = (_, s) => server = s;
final a = await r.lookup('example.test');
expect(a.map((x) => x.address), ['10.1.2.3']);
expect(server, '127.0.0.1');
final n = dns.queries.length;
expect(await r.lookup('EXAMPLE.test.'), a); // 대소문자 · 끝 점은 같은 이름
expect(dns.queries.length, n, reason: '기억한 답');
});
test('없는 이름은 "찾을 수 없음" (서버에 닿지 않음이 아님)', () async {
final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port);
final e = await r.lookup('nope.test').then<Object?>((_) => null, onError: (Object e) => e);
expect(e, isA<DnsException>());
expect((e as DnsException).unreachable, isFalse);
});
test('첫 서버가 답하지 않으면 보조 서버', () async {
// 127.0.0.2 에는 아무도 없다
final r = DnsResolver(config: () => custom('127.0.0.2', '127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 400));
final a = await r.lookup('example.test');
expect(a.single.address, '10.1.2.3');
});
test('잘린 답 (TC) 은 TCP 로 다시 묻는다', () async {
final t = FakeDns({'big.test': '10.9.9.9'}, truncateUdp: true);
await t.start();
addTearDown(t.close);
final r = DnsResolver(config: () => custom('127.0.0.1'), port: t.port);
expect((await r.lookup('big.test')).single.address, '10.9.9.9');
expect(t.tcpQueries, greaterThan(0));
});
test('서버에 닿지 않으면 unreachable 오류 (기기 DNS 로 바꾸지 않음)', () async {
final r = DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300));
final e = await r.lookup('www.google.com').then<Object?>((_) => null, onError: (Object e) => e);
expect(e, isA<DnsException>());
expect((e as DnsException).unreachable, isTrue);
expect(e.server, '127.0.0.2');
});
test('DNS over HTTPS (RFC 8484 POST)', () async {
final http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => http.close(force: true));
String? contentType;
http.listen((req) async {
contentType = req.headers.contentType?.mimeType;
final q = Uint8List.fromList(await req.fold<List<int>>([], (a, b) => a..addAll(b)));
// 받은 질문을 가짜 DNS 서버에 그대로 넘겨 답을 돌려준다
final s = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final got = Completer<Uint8List>();
s.listen((e) {
final d = s.receive();
if (d != null && !got.isCompleted) got.complete(d.data);
});
s.send(q, InternetAddress.loopbackIPv4, dns.port);
final a = await got.future;
s.close();
req.response.headers.contentType = ContentType('application', 'dns-message');
req.response.add(a);
await req.response.close();
});
final r = DnsResolver(
config: () => DnsConfig(mode: 'doh', dohUrl: 'http://127.0.0.1:${http.port}/dns-query', primary: '127.0.0.2'),
dohClient: HttpClient.new);
expect((await r.lookup('example.test')).single.address, '10.1.2.3');
expect(contentType, 'application/dns-message');
});
});
test('DNS 질문 · 답 읽기 (줄임 표시)', () {
final q = buildDnsQuery('a.example.com', 1, id: 0x1234);
expect(q.sublist(0, 2), [0x12, 0x34]);
// 답: 질문 + 줄임 표시 (0xc00c) A 1.2.3.4 TTL 60
final res = Uint8List.fromList([
0x12, 0x34, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0, ...q.sublist(12), //
0xc0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 1, 2, 3, 4,
]);
final a = parseDnsResponse(res, id: 0x1234);
expect(a.addresses.single.address, '1.2.3.4');
expect(a.ttl, 60);
expect(() => parseDnsResponse(res, id: 1), throwsFormatException);
});
test('같은 집 안 이름 · 주소 · DNS 서버 주소 확인', () {
for (final h in ['localhost', 'ds1815', 'nas.local', 'pc.lan', 'tab-s10.tail1234.ts.net', 'printer.home.arpa']) {
expect(isLocalName(h), isTrue, reason: h);
}
for (final h in ['www.youtube.com', 'github.com', '8.8.8.8']) {
expect(isLocalName(h), isFalse, reason: h);
}
for (final a in ['192.168.0.10', '10.0.0.1', '172.20.1.1', '100.80.195.34', '127.0.0.1', '::1', 'fe80::1']) {
expect(isPrivateAddress(InternetAddress(a)), isTrue, reason: a);
}
expect(isPrivateAddress(InternetAddress('8.8.8.8')), isFalse);
expect(parseDnsServer('8.8.8.8')?.address, '8.8.8.8');
expect(parseDnsServer(' 2001:4860:4860::8888 '), isNotNull);
expect(parseDnsServer('[2001:4860:4860::8844]'), isNotNull);
for (final bad in ['', '8.8.8', 'dns.google', '0.0.0.0', '::', '256.1.1.1']) {
expect(parseDnsServer(bad), isNull, reason: bad);
}
expect(isValidDohUrl('https://dns.google/dns-query'), isTrue);
expect(isValidDohUrl('https://cloudflare-dns.com/dns-query{?dns}'), isTrue);
expect(isValidDohUrl('http://dns.google/dns-query'), isFalse);
});
group('앱 안 프록시', () {
late FakeDns dns;
late HttpServer up;
late DnsResolver resolver;
late LocalProxy proxy;
final seen = <String>[];
setUp(() async {
dns = FakeDns({'up.test': '127.0.0.1'});
await dns.start();
up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
up.listen((req) async {
seen.add('${req.method} ${req.uri} ${req.headers.value('connection')}');
req.response.write('hello ${req.uri.path}');
await req.response.close();
});
resolver = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 500));
proxy = LocalProxy(DnsConnector(resolver));
await proxy.start();
seen.clear();
});
tearDown(() async {
await proxy.close();
await up.close(force: true);
await dns.close();
});
HttpClient client() => HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${proxy.port}');
test('http: 이름을 정한 DNS 로 풀어 잇고, 요청 줄은 경로만 · 한 연결에 요청 하나', () async {
final c = client();
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/a?b=1'))).close();
expect(await res.transform(utf8.decoder).join(), 'hello /a');
expect(seen.single, 'GET /a?b=1 close');
expect(dns.queries, contains('up.test/1'));
});
test('CONNECT: 터널 (https 와 같은 길) 을 정한 DNS 의 주소로', () async {
final s = await Socket.connect(InternetAddress.loopbackIPv4, proxy.port);
addTearDown(s.destroy);
final lines = StreamIterator(s.cast<List<int>>().transform(latin1.decoder));
s.write('CONNECT up.test:${up.port} HTTP/1.1\r\nHost: up.test:${up.port}\r\n\r\n');
var got = '';
while (!got.contains('\r\n\r\n') && await lines.moveNext()) {
got += lines.current;
}
expect(got, startsWith('HTTP/1.1 200'));
// 터널 안에서 원래 서버에 직접 말한다
s.write('GET /tunnel HTTP/1.1\r\nHost: up.test\r\nConnection: close\r\n\r\n');
var body = got.substring(got.indexOf('\r\n\r\n') + 4);
while (await lines.moveNext()) {
body += lines.current;
}
expect(body, contains('hello /tunnel'));
expect(dns.queries, contains('up.test/1'));
});
test('DNS 서버에 닿지 않으면 직접 잇지 않고 502 + 알림', () async {
final bad = LocalProxy(DnsConnector(DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300))));
Object? err;
bad.onError = (_, e) => err = e;
await bad.start();
addTearDown(bad.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bad.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/x'))).close();
expect(res.statusCode, 502);
await res.drain<void>();
expect(seen, isEmpty, reason: '원래 서버에 닿지 않았다');
expect(err, isA<DnsException>().having((e) => e.unreachable, 'unreachable', isTrue));
});
test('같은 집 안 이름 (localhost) 은 정한 DNS 에 묻지 않고 기기 DNS 로', () async {
final c = client();
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://localhost:${up.port}/lan'))).close();
expect(await res.transform(utf8.decoder).join(), 'hello /lan');
expect(dns.queries, isEmpty);
});
test('시스템 DNS (끄기) 면 정한 서버에 묻지 않는다', () async {
final sys = LocalProxy(DnsConnector(DnsResolver(config: () => const DnsConfig.system(), port: dns.port)));
await sys.start();
addTearDown(sys.close);
final s = await Socket.connect(InternetAddress.loopbackIPv4, sys.port);
addTearDown(s.destroy);
s.write('CONNECT 127.0.0.1:${up.port} HTTP/1.1\r\n\r\n');
final first = await s.cast<List<int>>().transform(latin1.decoder).first;
expect(first, startsWith('HTTP/1.1 200'));
expect(dns.queries, isEmpty);
});
});
group('Tor 다리 (SOCKS5)', () {
late ServerSocket socks;
late HttpServer up;
final asked = <String>[];
setUp(() async {
asked.clear();
up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
up.listen((req) async {
req.response.write('via tor');
await req.response.close();
});
// 가짜 Tor SOCKS5: 이름 (ATYP 3) 을 받아 적고 원래 서버로 잇는다
socks = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
socks.listen((c) async {
final r = SocketReader(c);
await r.read(3);
c.add([5, 0]);
final h = await r.read(4);
String host;
if (h[3] == 3) {
final n = (await r.read(1))[0];
host = 'name:${latin1.decode(await r.read(n))}';
} else {
host = 'ip:${InternetAddress.fromRawAddress(await r.read(h[3] == 1 ? 4 : 16)).address}';
}
final pb = await r.read(2);
asked.add('$host:${(pb[0] << 8) | pb[1]}');
final u = await Socket.connect(InternetAddress.loopbackIPv4, up.port);
c.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
unawaited(u.addStream(r.rest()).catchError((_) {}));
unawaited(c.addStream(u).then((_) => c.close()).catchError((_) {}));
});
});
tearDown(() async {
await socks.close();
await up.close(force: true);
});
test('이름을 그대로 Tor 에 넘긴다 (이 기기에서 DNS 를 묻지 않음)', () async {
final bridge = LocalProxy(TorConnector(() => socks.port));
await bridge.start();
addTearDown(bridge.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close();
expect(await res.transform(utf8.decoder).join(), 'via tor');
expect(asked.single, 'name:some-site.example:${up.port}');
});
test('Tor 가 준비되지 않았으면 502 (직접 잇지 않음)', () async {
final bridge = LocalProxy(TorConnector(() => null));
await bridge.start();
addTearDown(bridge.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close();
expect(res.statusCode, 502);
await res.drain<void>();
expect(asked, isEmpty);
});
});
group('앱 안 프록시 인증 (같은 기기의 다른 앱이 쓰지 못하게)', () {
late HttpServer up;
late LocalProxy proxy;
final auth = ProxyAuth('jj', 'secret-value');
setUp(() async {
up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
up.listen((req) async {
req.response.write('ok');
await req.response.close();
});
proxy = LocalProxy(DnsConnector(DnsResolver(config: () => const DnsConfig.system())), auth: auth);
await proxy.start();
});
tearDown(() async {
await proxy.close();
await up.close(force: true);
});
/// CONNECT 를 보내고 답의 첫 줄
Future<String> connect(String? authHeader) async {
final s = await Socket.connect(InternetAddress.loopbackIPv4, proxy.port);
addTearDown(s.destroy);
s.write('CONNECT 127.0.0.1:${up.port} HTTP/1.1\r\nHost: 127.0.0.1:${up.port}\r\n'
'${authHeader == null ? '' : 'Proxy-Authorization: $authHeader\r\n'}\r\n');
final got = await s.cast<List<int>>().transform(latin1.decoder).firstWhere((x) => x.contains('\r\n'));
return got.split('\r\n').first;
}
test('인증 없음 · 틀린 비밀값 · 다른 아이디는 407 (위로 잇지 않음)', () async {
final asked = <String>[];
proxy.onRequest = (m, h, p) => asked.add(h);
expect(await connect(null), startsWith('HTTP/1.1 407'));
expect(await connect('Basic ${base64.encode(utf8.encode('jj:wrong'))}'), startsWith('HTTP/1.1 407'));
expect(await connect('Basic ${base64.encode(utf8.encode('xx:secret-value'))}'), startsWith('HTTP/1.1 407'));
expect(await connect('Bearer secret-value'), startsWith('HTTP/1.1 407'));
expect(asked, isEmpty);
});
test('맞는 비밀값은 통과 (CONNECT · 앱의 HttpClient)', () async {
expect(await connect(auth.header), startsWith('HTTP/1.1 200'));
final c = HttpClient()..findProxy = ((_) => 'PROXY ${auth.user}:${auth.password}@127.0.0.1:${proxy.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://127.0.0.1:${up.port}/'))).close();
expect(res.statusCode, 200);
expect(await res.transform(utf8.decoder).join(), 'ok');
// 비밀값 없이는 407
final bare = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${proxy.port}');
addTearDown(bare.close);
final res2 = await (await bare.getUrl(Uri.parse('http://127.0.0.1:${up.port}/'))).close();
expect(res2.statusCode, 407);
});
test('실행마다 다른 비밀값 · 127.0.0.1 에만 열림', () async {
expect(ProxyAuth.instance.password.length, greaterThanOrEqualTo(30));
expect(ProxyAuth.instance.password, isNot(contains(':')));
expect(ProxyAuth.instance.password, isNot(contains('@')));
// 127.0.0.1 이 아닌 이 기기 주소로는 닿지 않는다
final others = [
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4))
for (final a in i.addresses)
if (!a.isLoopback) a,
];
for (final a in others) {
await expectLater(Socket.connect(a, proxy.port, timeout: const Duration(seconds: 2)), throwsA(isA<SocketException>()));
}
});
});
test('NetGuard: 앱의 HTTP · yt-dlp · aria2 가 프록시를 쓰는 조건', () async {
final g = NetGuard.instance;
var cfg = custom('8.8.8.8', '8.8.4.4');
g.config = () => cfg;
await g.ensure();
final port = g.port!;
final a = ProxyAuth.instance;
// 비밀값을 함께 (같은 기기의 다른 앱은 이 프록시를 쓰지 못한다)
expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'PROXY ${a.user}:${a.password}@127.0.0.1:$port');
expect(g.findProxy(Uri.parse('http://192.168.0.5:5005/')), 'DIRECT');
expect(g.findProxy(Uri.parse('https://ds1815:5006/')), 'DIRECT');
expect(g.findProxy(Uri.parse('http://127.0.0.1:6800/jsonrpc')), 'DIRECT');
expect(g.ytDlpArgs(), ['--proxy', 'http://${a.user}:${a.password}@127.0.0.1:$port']);
final aria = ['--all-proxy=http://127.0.0.1:$port', '--all-proxy-user=${a.user}', '--all-proxy-passwd=${a.password}'];
expect(g.aria2Args(asyncDns: true), [...aria, '--async-dns=true', '--async-dns-server=8.8.8.8,8.8.4.4']);
expect(g.aria2Args(asyncDns: false), aria);
cfg = const DnsConfig.system();
expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'DIRECT');
expect(g.ytDlpArgs(), isEmpty);
expect(g.aria2Args(asyncDns: true), isEmpty);
expect(g.browserProxy, isNull);
});
test('설정: 처음은 직접 지정 8.8.8.8 · 8.8.4.4, 틀린 값은 처음 값, 사용자가 고른 값은 그대로', () {
final s = AppSettings();
expect((s.dnsMode, s.dnsPrimary, s.dnsSecondary, s.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query'));
expect(AppSettings.fromJson({}).dnsMode, 'custom');
final bad = AppSettings.fromJson({'dnsMode': 'x', 'dnsPrimary': 'dns.google', 'dnsSecondary': '1.2.3', 'dnsDohUrl': 'ftp://x'});
expect((bad.dnsMode, bad.dnsPrimary, bad.dnsSecondary, bad.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query'));
final mine = AppSettings.fromJson(
AppSettings().toJson()..addAll({'dnsMode': 'system', 'dnsPrimary': '1.1.1.1', 'dnsSecondary': '', 'dnsDohUrl': 'https://cloudflare-dns.com/dns-query'}));
expect((mine.dnsMode, mine.dnsPrimary, mine.dnsSecondary, mine.dnsDohUrl), ('system', '1.1.1.1', '', 'https://cloudflare-dns.com/dns-query'));
final back = AppSettings.fromJson(mine.toJson());
expect(back.dnsConfig.key, mine.dnsConfig.key);
// Tor 설정 처음 값
expect((s.torKeepData, s.torHomeUrl, s.torJavaScript, s.torNoticeSeen), (false, 'https://check.torproject.org/', true, false));
});
}