import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:ikar/core/transport/ikar_network.dart'; import 'live_ikar_smoke.dart' as live; List attr(int type, List value) { final h = ByteData(4) ..setUint16(0, type) ..setUint16(2, value.length); return [ ...h.buffer.asUint8List(), ...value, ...List.filled((4 - value.length % 4) % 4, 0), ]; } class Frame { Frame(this.bytes) { final header = ByteData.sublistView(bytes); type = header.getUint16(0); for (var offset = 20; offset + 4 <= bytes.length;) { final at = header.getUint16(offset), length = header.getUint16(offset + 2); attributes[at] = bytes.sublist(offset + 4, offset + 4 + length); offset += 4 + length + (4 - length % 4) % 4; } } final Uint8List bytes; late final int type; final attributes = {}; String get transaction => base64Encode(bytes.sublist(8, 20)); } class Turn { Turn(Socket socket) : write = socket.add, stop = socket.destroy { socket.listen( (chunk) { buffer.addAll(chunk); while (buffer.length >= 20) { final size = 20 + (buffer[2] << 8 | buffer[3]); if (buffer.length < size) break; frames.add(Frame(Uint8List.fromList(buffer.sublist(0, size)))); buffer.removeRange(0, size); } }, onError: frames.addError, onDone: frames.close, ); } Turn.datagram(RawDatagramSocket socket, InternetAddress address) : write = ((bytes) => socket.send(bytes, address, 3478)), stop = socket.close { socket.listen( (event) { if (event != RawSocketEvent.read) return; Datagram? packet; while ((packet = socket.receive()) != null) { if (packet!.address.address == address.address && packet.port == 3478) { frames.add(Frame(packet.data)); } } }, onError: frames.addError, onDone: frames.close, ); } final void Function(List) write; final void Function() stop; final frames = StreamController.broadcast(); final buffer = []; late String username, password, realm, nonce; late (String, int) relay; Uint8List encode(int type, List body, {bool auth = false}) { final header = ByteData(20) ..setUint16(0, type) ..setUint16(2, body.length + (auth ? 24 : 0)) ..setUint32(4, 0x2112a442); final bytes = header.buffer.asUint8List(); final random = Random.secure(); for (var i = 8; i < 20; i++) { bytes[i] = random.nextInt(256); } final prefix = [...bytes, ...body]; if (!auth) return Uint8List.fromList(prefix); final key = md5.convert(utf8.encode('$username:$realm:$password')).bytes; final integrity = Hmac(sha1, key).convert(prefix).bytes; return Uint8List.fromList([...prefix, ...attr(8, integrity)]); } Future request(int type, List body, {bool auth = false}) async { final credentials = auth ? [ ...attr(6, utf8.encode(username)), ...attr(0x14, utf8.encode(realm)), ...attr(0x15, utf8.encode(nonce)), ] : []; final wire = encode(type, [...credentials, ...body], auth: auth); final transaction = base64Encode(wire.sublist(8, 20)); final response = frames.stream .firstWhere((f) => f.transaction == transaction) .timeout(const Duration(seconds: 10)); write(wire); return response; } List peer((String, int) address) { final value = ByteData(8) ..setUint8(1, 1) ..setUint16(2, address.$2 ^ 0x2112); final ip = InternetAddress(address.$1).rawAddress; final magic = [0x21, 0x12, 0xa4, 0x42]; for (var i = 0; i < 4; i++) { value.setUint8(4 + i, ip[i] ^ magic[i]); } return attr(0x12, value.buffer.asUint8List()); } Future allocate(String user, String secret) async { username = user; password = secret; final transport = attr(0x19, [17, 0, 0, 0]); final challenge = await request(3, transport); live.check( challenge.type == 0x113, 'TURN did not issue an authentication challenge', ); realm = utf8.decode(challenge.attributes[0x14]!); nonce = utf8.decode(challenge.attributes[0x15]!); live.check(realm == 'ikar.kusoft.xyz', 'Incorrect TURN realm'); final result = await request(3, transport, auth: true); live.check(result.type == 0x103, 'TURN allocation rejected'); final value = ByteData.sublistView(result.attributes[0x16]!); live.check(value.getUint8(1) == 1, 'Unexpected relay address family'); final port = value.getUint16(2) ^ 0x2112; final magic = [0x21, 0x12, 0xa4, 0x42]; final ip = List.generate( 4, (i) => value.getUint8(4 + i) ^ magic[i], ).join('.'); relay = (ip, port); } Future permission((String, int) address) async { live.check( (await request(8, peer(address), auth: true)).type == 0x108, 'TURN permission rejected', ); } void send((String, int) address, String message) => write( encode(0x16, [...peer(address), ...attr(0x13, utf8.encode(message))]), ); Future receive() => frames.stream .firstWhere((f) => f.type == 0x17) .timeout(const Duration(seconds: 10)) .then((f) => utf8.decode(f.attributes[0x13]!)); void close() => stop(); } Future main(List args) async { final udp = args.contains('--udp'); final hosts = args.where((arg) => !arg.startsWith('--')).toList(); final devices = []; final relays = []; WebSocket? signaling; try { for (var i = 0; i < 2; i++) { devices.add( await live.Device.create( Uri.parse('https://ikar.kusoft.xyz'), Platform.environment['IKAR_TEST_NODE'] ?? 'node', '+999${DateTime.now().microsecondsSinceEpoch}$i', 'IKAR TURN test', ), ); } final call = await devices[0].ok(78, { 'calleeIds': [devices[1].id], 'isVideo': false, 'conversationId': 'ikar-turn-${DateTime.now().microsecondsSinceEpoch}', }); signaling = await IkarNetwork.connectWebSocket( jsonDecode(call['internalCallerParams'])['endpoint'], ); final hello = jsonDecode(await signaling.first as String); final turn = hello['conversationParams']['turn']; final address = hosts.isNotEmpty ? hosts.first : RegExp( r'^turn:([^:?/]+)', ).firstMatch((turn['urls'] as List).first)!.group(1)!; for (var i = 0; i < 2; i++) { final relay = udp ? Turn.datagram( await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0), (await InternetAddress.lookup( address, type: InternetAddressType.IPv4, )).first, ) : Turn( await Socket.connect( address, 3478, ).timeout(const Duration(seconds: 10)), ); relays.add(relay); await relay.allocate(turn['username'], turn['credential']); } final a = relays[0], b = relays[1]; await a.permission(b.relay); await b.permission(a.relay); final incoming = b.receive(); a.send(b.relay, 'IKAR public TURN'); live.check( await incoming == 'IKAR public TURN', 'TURN forward data not relayed', ); final reply = a.receive(); b.send(a.relay, 'IKAR public TURN reply'); live.check( await reply == 'IKAR public TURN reply', 'TURN reply not relayed', ); final report = { 'passed': true, 'transport': udp ? 'UDP' : 'TCP', 'serverAddress': address, 'externalAccess': hosts.isEmpty, 'relayPorts': [a.relay.$2, b.relay.$2], 'bidirectionalData': true, 'client': 'Windows', 'timestamp': DateTime.now().toUtc().toIso8601String(), }; await File( 'artifacts/${hosts.isEmpty ? 'public' : 'local'}-turn-verification${udp ? '-udp' : ''}.json', ).writeAsString(const JsonEncoder.withIndent(' ').convert(report)); stdout.writeln(jsonEncode(report)); } finally { for (final relay in relays) { relay.close(); } await signaling?.close(); for (final d in devices) { await d.ok(199, {}); await d.close(); } } }