Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
256 lines
8.3 KiB
Dart
256 lines
8.3 KiB
Dart
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<int> attr(int type, List<int> 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 = <int, Uint8List>{};
|
|
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<int>) write;
|
|
final void Function() stop;
|
|
final frames = StreamController<Frame>.broadcast();
|
|
final buffer = <int>[];
|
|
late String username, password, realm, nonce;
|
|
late (String, int) relay;
|
|
Uint8List encode(int type, List<int> 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<Frame> request(int type, List<int> body, {bool auth = false}) async {
|
|
final credentials = auth
|
|
? [
|
|
...attr(6, utf8.encode(username)),
|
|
...attr(0x14, utf8.encode(realm)),
|
|
...attr(0x15, utf8.encode(nonce)),
|
|
]
|
|
: <int>[];
|
|
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<int> 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<void> 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<void> 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<String> receive() => frames.stream
|
|
.firstWhere((f) => f.type == 0x17)
|
|
.timeout(const Duration(seconds: 10))
|
|
.then((f) => utf8.decode(f.attributes[0x13]!));
|
|
void close() => stop();
|
|
}
|
|
|
|
Future<void> main(List<String> args) async {
|
|
final udp = args.contains('--udp');
|
|
final hosts = args.where((arg) => !arg.startsWith('--')).toList();
|
|
final devices = <live.Device>[];
|
|
final relays = <Turn>[];
|
|
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();
|
|
}
|
|
}
|
|
}
|