diff --git a/leenkx/Sources/leenkx/network/torrent/Aegis128x4.hx b/leenkx/Sources/leenkx/network/torrent/Aegis128x4.hx new file mode 100644 index 00000000..f6469050 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Aegis128x4.hx @@ -0,0 +1,209 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class Aegis128x4 { + static inline var D = 4; + static inline var RATE = 128; + + static var C0 = Bytes.ofHex("000101020305080d1522375990e97962"); + static var C1 = Bytes.ofHex("db3d18556dc22ff12011314273b528dd"); + + var v:Bytes; + var nv:Bytes; + var t:Bytes; + var t2:Bytes; + + public function new(key:Bytes, nonce:Bytes) { + v = Bytes.alloc(512); + nv = Bytes.alloc(512); + t = Bytes.alloc(16); + t2 = Bytes.alloc(16); + init(key, nonce); + } + + static inline function off(j:Int, i:Int):Int { + return (j * D + i) * 16; + } + + static inline function xtime(x:Int):Int { + return ((x << 1) ^ ((x >> 7) * 0x1b)) & 0xff; + } + + static function xorInto(a:Bytes, ao:Int, b:Bytes, bo:Int, out:Bytes, oo:Int):Void { + for (k in 0...16) out.set(oo + k, a.get(ao + k) ^ b.get(bo + k)); + } + + function aesRound(inOff:Int, rk:Bytes, rkOff:Int, out:Bytes, outOff:Int):Void { + for (c in 0...4) for (r in 0...4) + t2.set(4 * c + r, SBOX[v.get(inOff + 4 * ((c + r) & 3) + r)]); + for (c in 0...4) { + var b0 = t2.get(4 * c); + var b1 = t2.get(4 * c + 1); + var b2 = t2.get(4 * c + 2); + var b3 = t2.get(4 * c + 3); + out.set(outOff + 4 * c, (xtime(b0) ^ xtime(b1) ^ b1 ^ b2 ^ b3) ^ rk.get(rkOff + 4 * c)); + out.set(outOff + 4 * c + 1, (b0 ^ xtime(b1) ^ xtime(b2) ^ b2 ^ b3) ^ rk.get(rkOff + 4 * c + 1)); + out.set(outOff + 4 * c + 2, (b0 ^ b1 ^ xtime(b2) ^ xtime(b3) ^ b3) ^ rk.get(rkOff + 4 * c + 2)); + out.set(outOff + 4 * c + 3, (xtime(b0) ^ b0 ^ b1 ^ b2 ^ xtime(b3)) ^ rk.get(rkOff + 4 * c + 3)); + } + } + + function update(m0:Bytes, m0off:Int, m1:Bytes, m1off:Int):Void { + for (i in 0...D) { + xorInto(v, off(0, i), m0, m0off + i * 16, t, 0); + aesRound(off(7, i), t, 0, nv, off(0, i)); + aesRound(off(0, i), v, off(1, i), nv, off(1, i)); + aesRound(off(1, i), v, off(2, i), nv, off(2, i)); + aesRound(off(2, i), v, off(3, i), nv, off(3, i)); + xorInto(v, off(4, i), m1, m1off + i * 16, t, 0); + aesRound(off(3, i), t, 0, nv, off(4, i)); + aesRound(off(4, i), v, off(5, i), nv, off(5, i)); + aesRound(off(5, i), v, off(6, i), nv, off(6, i)); + aesRound(off(6, i), v, off(7, i), nv, off(7, i)); + } + v.blit(0, nv, 0, 512); + } + + function init(key:Bytes, nonce:Bytes):Void { + var kn = Bytes.alloc(16); + var kc0 = Bytes.alloc(16); + var kc1 = Bytes.alloc(16); + for (k in 0...16) { + kn.set(k, key.get(k) ^ nonce.get(k)); + kc0.set(k, key.get(k) ^ C0.get(k)); + kc1.set(k, key.get(k) ^ C1.get(k)); + } + for (i in 0...D) { + v.blit(off(0, i), kn, 0, 16); + v.blit(off(1, i), C1, 0, 16); + v.blit(off(2, i), C0, 0, 16); + v.blit(off(3, i), C1, 0, 16); + v.blit(off(4, i), kn, 0, 16); + v.blit(off(5, i), kc0, 0, 16); + v.blit(off(6, i), kc1, 0, 16); + v.blit(off(7, i), kc0, 0, 16); + } + var m0 = Bytes.alloc(64); + var m1 = Bytes.alloc(64); + for (i in 0...D) { + m0.blit(i * 16, nonce, 0, 16); + m1.blit(i * 16, key, 0, 16); + } + for (r in 0...10) { + for (i in 0...D) { + v.set(off(3, i), v.get(off(3, i)) ^ i); + v.set(off(3, i) + 1, v.get(off(3, i) + 1) ^ (D - 1)); + v.set(off(7, i), v.get(off(7, i)) ^ i); + v.set(off(7, i) + 1, v.get(off(7, i) + 1) ^ (D - 1)); + } + update(m0, 0, m1, 0); + } + } + + public function absorb(ai:Bytes, aiOff:Int):Void { + update(ai, aiOff, ai, aiOff + 64); + } + + public function enc(xi:Bytes, xiOff:Int):Bytes { + var out = Bytes.alloc(RATE); + for (i in 0...D) { + for (k in 0...16) { + var z0 = v.get(off(1, i) + k) ^ v.get(off(6, i) + k) + ^ (v.get(off(2, i) + k) & v.get(off(3, i) + k)); + var z1 = v.get(off(2, i) + k) ^ v.get(off(5, i) + k) + ^ (v.get(off(6, i) + k) & v.get(off(7, i) + k)); + out.set(i * 16 + k, xi.get(xiOff + i * 16 + k) ^ z0); + out.set(64 + i * 16 + k, xi.get(xiOff + 64 + i * 16 + k) ^ z1); + } + } + update(xi, xiOff, xi, xiOff + 64); + return out; + } + + public function nextBlock():Bytes { + var out = Bytes.alloc(RATE); + for (i in 0...D) { + for (k in 0...16) { + out.set(i * 16 + k, v.get(off(1, i) + k) ^ v.get(off(6, i) + k) + ^ (v.get(off(2, i) + k) & v.get(off(3, i) + k))); + out.set(64 + i * 16 + k, v.get(off(2, i) + k) ^ v.get(off(5, i) + k) + ^ (v.get(off(6, i) + k) & v.get(off(7, i) + k))); + } + } + for (i in 0...D) { + aesRound(off(7, i), v, off(0, i), nv, off(0, i)); + aesRound(off(0, i), v, off(1, i), nv, off(1, i)); + aesRound(off(1, i), v, off(2, i), nv, off(2, i)); + aesRound(off(2, i), v, off(3, i), nv, off(3, i)); + aesRound(off(3, i), v, off(4, i), nv, off(4, i)); + aesRound(off(4, i), v, off(5, i), nv, off(5, i)); + aesRound(off(5, i), v, off(6, i), nv, off(6, i)); + aesRound(off(6, i), v, off(7, i), nv, off(7, i)); + } + v.blit(0, nv, 0, 512); + return out; + } + + public static function selfTest():Bool { + var key = Bytes.ofHex("000102030405060708090a0b0c0d0e0f"); + var nonce = Bytes.ofHex("101112131415161718191a1b1c1d1e1f"); + var a = new Aegis128x4(key, nonce); + var expected = Bytes.ofHex( + "924eb07635003a37e6c6575ba8ce1929c8b6a5d91475445e936d48e794be0ce2" + + "fcd37d050e24084befe3bbb219d647602e9f58cfb893a8800220242c373a8b18" + + "1a1f60c4fab64e5471dc72edfcf6fe6bc1e525ebea2d6375a9edd045dce96381" + + "97a3e25abd228a44d4a14a6d3fe9185cc2d4cf7f4287a98744645674265d4ca8" + + "7bb50c534f6ec4780530ff1cce8a16e87b08d57557da0b5ef7b5f7d98b0ba189" + + "6bfcac34ddb68404821a4d665303cb0fd95626f6dfad1aed7467622c38529932" + + "af339fd2d50ee45fc47665c647cf6586d0669b39d140f0e118a4a511efe2f95a" + + "7a94330f35c194fadda2a87e42cdeccc233b640d1f4d56e2757e72c1a9d8ecb1" + + "9f93737d699ba05c11e94f2b201bef5e61caf387cf7cfd3f8300ac7680ccfd76" + + "5825a671ecef03b7a9c98a601ae3211587a1fe4d558161a8f4c38731f3223032" + + "7a5aca78d636c05bbc702b2980196ab6915d868408495d07eb527789f282c575" + + "d0947bfbc1d3309cdffc9be1503aea628834ea57a15b9fbdc0245464a4b8cbef" + + "e46f4cf71a95ac45b6f0823e3aba1a868c4ecef682fc44a8eba911b3fc7d99f9" + + "a4fb61e2c928a2ca760b8772f2ea5f2e3d34ea89da73caa3016c280500a155a3" + + "85075f0080e9d618e7eb40f57c32d9f7d2ab2b320c6e93b155a3787cb83e5281" + + "0b3af0250ae36831a1b072e499929bcb5cce4d00329d69f1aae36aa541347512"); + if (expected.compare(a.v) != 0) { + return false; + } + var ad = Bytes.alloc(128); + for (k in 0...8) ad.set(k, [1, 2, 3, 4][k & 3]); + a.absorb(ad, 0); + var msg = Bytes.alloc(128); + for (k in 0...120) msg.set(k, [4, 5, 6, 7][k & 3]); + var ct = a.enc(msg, 0); + var expectedCt = Bytes.ofHex( + "e836118562f4479c9d35c17356a833114c21f9aa39e4dda5e5c87f4152a00fce" + + "9a7c38f832eafe8b1c12f8a7cf12a81a1ad8a9c24ba9dedfbdaa586ffea67ddc" + + "801ea97d9ab4a872f42d0e352e2713dacd609f9442c17517c5a29daf3e2a3fac" + + "4ff6b1380c4e46df7b086af6ce6bc1ed594b8dd64aed2a7e"); + for (k in 0...120) { + if (ct.get(k) != expectedCt.get(k)) { + return false; + } + } + return true; + } + + static var SBOX = [ + 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, + 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, + 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, + 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, + 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, + 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, + 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, + 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, + 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, + 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, + 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, + 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, + 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, + 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, + 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, + 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16 + ]; +} diff --git a/leenkx/Sources/leenkx/network/torrent/Base58.hx b/leenkx/Sources/leenkx/network/torrent/Base58.hx new file mode 100644 index 00000000..b1af8ac0 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Base58.hx @@ -0,0 +1,119 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class Base58 { + + static var ALPHABET:String = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + static var ALPHABET_MAP:Map = { + var m = new Map(); + for (i in 0...ALPHABET.length) { + m.set(ALPHABET.charCodeAt(i), i); + } + m; + }; + + public static function encode(bytes:Bytes):String { + if (bytes.length == 0) return ""; + + var zeros = 0; + while (zeros < bytes.length && bytes.get(zeros) == 0) zeros++; + + var size = Math.ceil(bytes.length * 138 / 100 + 1); + var b58 = [for (i in 0...size) 0]; + + for (i in zeros...bytes.length) { + var carry = bytes.get(i); + for (j in 0...size) { + var idx = size - 1 - j; + carry += b58[idx] << 8; + b58[idx] = carry % 58; + carry = Std.int(carry / 58); + } + } + + var leadingZeros = 0; + for (i in 0...size) { + if (b58[i] != 0) break; + leadingZeros++; + } + + var sb = new StringBuf(); + for (i in 0...zeros) sb.addChar(ALPHABET.charCodeAt(0)); + for (i in leadingZeros...size) { + sb.addChar(ALPHABET.charCodeAt(b58[i])); + } + return sb.toString(); + } + + public static function decode(str:String):Bytes { + if (str.length == 0) return Bytes.alloc(0); + + var zeros = 0; + while (zeros < str.length && str.charCodeAt(zeros) == ALPHABET.charCodeAt(0)) zeros++; + + var size = Math.ceil(str.length * 733 / 1000 + 1); + var bytes = [for (i in 0...size) 0]; + + for (i in zeros...str.length) { + var ch = str.charCodeAt(i); + if (!ALPHABET_MAP.exists(ch)) { + throw "Base58: invalid character " + str.charAt(i); + } + var carry = ALPHABET_MAP.get(ch); + for (j in 0...size) { + var idx = size - 1 - j; + carry += bytes[idx] * 58; + bytes[idx] = carry & 0xff; + carry = carry >> 8; + } + } + + var leadingZeros = 0; + for (i in 0...size) { + if (bytes[i] != 0) break; + leadingZeros++; + } + + var result = Bytes.alloc(zeros + size - leadingZeros); + for (i in 0...zeros) result.set(i, 0); + for (i in 0...(size - leadingZeros)) { + result.set(zeros + i, bytes[leadingZeros + i]); + } + return result; + } +} + +class Base58Check { + + static var CHECKSUM_LEN = 4; + + public static function encode(bytes:Bytes):String { + var hash = sha256sha256(bytes); + var payload = Bytes.alloc(bytes.length + CHECKSUM_LEN); + payload.blit(0, bytes, 0, bytes.length); + payload.blit(bytes.length, hash, 0, CHECKSUM_LEN); + return Base58.encode(payload); + } + + public static function decode(str:String):Bytes { + var payload = Base58.decode(str); + if (payload.length < CHECKSUM_LEN) { + throw "Base58Check: too short"; + } + var data = payload.sub(0, payload.length - CHECKSUM_LEN); + var checksum = payload.sub(payload.length - CHECKSUM_LEN, CHECKSUM_LEN); + var hash = sha256sha256(data); + for (i in 0...CHECKSUM_LEN) { + if (hash.get(i) != checksum.get(i)) { + throw "Base58Check: checksum mismatch"; + } + } + return data; + } + + static function sha256sha256(bytes:Bytes):Bytes { + var h1 = haxe.crypto.Sha256.make(bytes); + return haxe.crypto.Sha256.make(h1); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Bencode.hx b/leenkx/Sources/leenkx/network/torrent/Bencode.hx new file mode 100644 index 00000000..1d2b7c8b --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Bencode.hx @@ -0,0 +1,182 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class RawDict { + public var pairs:Array<{key:Bytes, val:Dynamic}> = []; + public function new() {} +} + +class Bencode { + + public static function encode(value:Dynamic):Bytes { + var out = new haxe.io.BytesOutput(); + encodeValue(out, value); + return out.getBytes(); + } + + public static function encodeToString(value:Dynamic):String { + return encode(value).toString(); + } + + static function encodeValue(out:haxe.io.BytesOutput, value:Dynamic):Void { + if (Std.isOfType(value, Bool)) { + encodeInt(out, value ? 1 : 0); + } else if (Std.isOfType(value, Int) || Std.isOfType(value, Float)) { + encodeInt(out, value); + } else if (Std.isOfType(value, String)) { + encodeString(out, value); + } else if (Std.isOfType(value, Bytes)) { + encodeBytes(out, value); + } else if (Std.isOfType(value, RawDict)) { + encodeRawDict(out, value); + } else if (Std.isOfType(value, Array)) { + encodeList(out, value); + } else if (value != null && Reflect.isObject(value)) { + encodeDict(out, value); + } else { + throw "Bencode: cannot encode " + Type.typeof(value); + } + } + + static function encodeInt(out:haxe.io.BytesOutput, value:Float):Void { + out.writeString("i"); + var s = Std.string(value); + var dot = s.indexOf("."); + if (dot != -1) s = s.substring(0, dot); + if (s.indexOf("e") != -1 || s.indexOf("E") != -1) { + s = Std.string(Math.floor(value)); + dot = s.indexOf("."); + if (dot != -1) s = s.substring(0, dot); + } + out.writeString(s); + out.writeString("e"); + } + + static function encodeString(out:haxe.io.BytesOutput, value:String):Void { + var bytes = Bytes.ofString(value); + encodeBytes(out, bytes); + } + + static function encodeBytes(out:haxe.io.BytesOutput, bytes:Bytes):Void { + out.writeString(Std.string(bytes.length)); + out.writeString(":"); + out.writeBytes(bytes, 0, bytes.length); + } + + static function encodeRawDict(out:haxe.io.BytesOutput, dict:RawDict):Void { + out.writeString("d"); + for (p in dict.pairs) { + encodeBytes(out, p.key); + encodeValue(out, p.val); + } + out.writeString("e"); + } + + static function encodeList(out:haxe.io.BytesOutput, arr:Array):Void { + out.writeString("l"); + for (item in arr) { + encodeValue(out, item); + } + out.writeString("e"); + } + + static function encodeDict(out:haxe.io.BytesOutput, obj:Dynamic):Void { + out.writeString("d"); + var keys = Reflect.fields(obj); + keys.sort(Reflect.compare); + for (key in keys) { + if (Reflect.field(obj, key) == null) continue; + encodeString(out, key); + encodeValue(out, Reflect.field(obj, key)); + } + out.writeString("e"); + } + + public static function decode(bytes:Bytes):Dynamic { + return new Decoder(bytes).value(); + } + + public static function decodeString(str:String):Dynamic { + return decode(Bytes.ofString(str)); + } +} + +private class Decoder { + static inline var MAX_DECODE_LEN = 10 * 1024 * 1024; // 10 MB + + var data:Bytes; + var pos:Int = 0; + + public function new(data:Bytes) { + this.data = data; + } + + public function value():Dynamic { + if (pos >= data.length) throw "Bencode: truncated input"; + var b = data.get(pos++); + switch (b) { + case 'i'.code: + return decodeInt(); + case 'l'.code: + return decodeList(); + case 'd'.code: + return decodeDict(); + default: + return decodeBytes(); + } + } + + function decodeInt():Float { + var start = pos; + while (pos < data.length && data.get(pos) != 'e'.code) pos++; + if (pos >= data.length) throw "Bencode: unterminated int"; + var s = data.getString(start, pos - start); + pos++; + var v = Std.parseFloat(s); + if (Math.isNaN(v)) throw "Bencode: bad int " + s; + return v; + } + + function decodeBytes():Bytes { + var start = pos - 1; + if (data.get(start) < '0'.code || data.get(start) > '9'.code) { + throw "Bencode: bad byte length"; + } + while (pos < data.length && data.get(pos) != ':'.code) pos++; + if (pos >= data.length) throw "Bencode: unterminated length"; + var len = Std.parseInt(data.getString(start, pos - start)); + pos++; + if (len == null || len < 0 || len > MAX_DECODE_LEN + || pos + len > data.length) { + throw "Bencode: invalid byte length " + len; + } + var bytes = Bytes.alloc(len); + bytes.blit(0, data, pos, len); + pos += len; + return bytes; + } + + function decodeList():Array { + var arr:Array = []; + while (true) { + if (pos >= data.length) throw "Bencode: unterminated list"; + if (data.get(pos) == 'e'.code) break; + arr.push(value()); + } + pos++; + return arr; + } + + function decodeDict():Dynamic { + var obj:Dynamic = {}; + while (true) { + if (pos >= data.length) throw "Bencode: unterminated dict"; + if (data.get(pos) == 'e'.code) break; + var key:Bytes = value(); + Reflect.setField(obj, key.toString(), value()); + } + pos++; + return obj; + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Crypto.hx b/leenkx/Sources/leenkx/network/torrent/Crypto.hx new file mode 100644 index 00000000..c7d14113 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Crypto.hx @@ -0,0 +1,152 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.Base58.Base58Check; + +class Crypto { + + public static var SEEDPREFIX(default, null) = "490a"; + public static var ADDRESSPREFIX(default, null) = "55"; + + static var drbg:Aegis128x4 = null; + static var drbgBuf:Bytes = null; + static var drbgPos:Int = 0; + static var drbgNative:Int = -1; + + public static function randomBytes(length:Int):Bytes { + var bytes = Bytes.alloc(length); + #if (js && !kha_krom) + var webCrypto:Dynamic = js.Syntax.code("(typeof globalThis !== 'undefined' ? globalThis.crypto : null)"); + if (webCrypto != null && webCrypto.getRandomValues != null) { + var view = new js.lib.Uint8Array(length); + webCrypto.getRandomValues(view); + for (i in 0...length) bytes.set(i, view[i]); + return bytes; + } + #end + #if lnx_datachannel + if (drbgNative == -1) { + var seed = osEntropy(32); + try { + drbgNative = datachannel.RTC.aegisAvailable() + && datachannel.RTC.aegisInit(seed.sub(0, 16), seed.sub(16, 16)) ? 1 : 0; + } catch (e:Dynamic) { + drbgNative = 0; + } + } + if (drbgNative == 1) { + return datachannel.RTC.aegisRandom(length); + } + #end + if (drbg == null) { + var seed = osEntropy(32); + drbg = new Aegis128x4(seed.sub(0, 16), seed.sub(16, 16)); + drbgBuf = Bytes.alloc(0); + drbgPos = 0; + } + var off = 0; + while (off < length) { + if (drbgPos >= drbgBuf.length) { + drbgBuf = drbg.nextBlock(); + drbgPos = 0; + } + var n = Std.int(Math.min(drbgBuf.length - drbgPos, length - off)); + bytes.blit(off, drbgBuf, drbgPos, n); + drbgPos += n; + off += n; + } + return bytes; + } + + static function osEntropy(length:Int):Bytes { + var bytes = Bytes.alloc(length); + #if kha_krom + var kromBytes = leenkx.network.torrent.transport.RtcNative.randomBytes(length); + if (kromBytes != null) return kromBytes; + #end + #if sys + if (Sys.systemName() != "Windows") { + try { + var f = sys.io.File.read("/dev/urandom", true); + f.readBytes(bytes, 0, length); + f.close(); + return bytes; + } catch(e:Dynamic) {} + } else { + try { + var p = new sys.io.Process("powershell", [ + "-NoProfile", "-Command", + "$r = New-Object 'byte[]' " + length + "; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($r); -join ($r | ForEach-Object { $_.ToString('x2') })" + ]); + var hex = StringTools.trim(p.stdout.readAll().toString()); + p.close(); + if (hex.length == length * 2) { + return Bytes.ofHex(hex); + } + } catch(e:Dynamic) { + trace("Windows RNG via PowerShell failed: " + e); + } + } + #end + throw "Crypto: OS entropy unavailable. Refusing to generate predictable keys."; + } + + public static function hash(data:Bytes):Bytes { + return TweetNaCl.hash(data); + } + + public static function signDetached(message:Bytes, secretKey:Bytes):Bytes { + return TweetNaCl.signDetached(message, secretKey); + } + + public static function signDetachedVerify(message:Bytes, signature:Bytes, publicKey:Bytes):Bool { + return TweetNaCl.signDetachedVerify(message, signature, publicKey); + } + + public static function box(message:Bytes, nonce:Bytes, theirPublicKey:Bytes, mySecretKey:Bytes):Bytes { + return TweetNaCl.box(message, nonce, theirPublicKey, mySecretKey); + } + + public static function boxOpen(message:Bytes, nonce:Bytes, theirPublicKey:Bytes, mySecretKey:Bytes):Bytes { + return TweetNaCl.boxOpen(message, nonce, theirPublicKey, mySecretKey); + } + + public static function boxKeyPair():{publicKey:Bytes, secretKey:Bytes} { + return TweetNaCl.boxKeyPair(); + } + + public static function signKeyPairFromSeed(seed:Bytes):{publicKey:Bytes, secretKey:Bytes} { + return TweetNaCl.signKeyPairFromSeed(seed); + } + + public static var boxNonceLength(get, null):Int; + static function get_boxNonceLength():Int { + return TweetNaCl.crypto_box_NONCEBYTES; + } + + public static function ripemd160(data:Bytes):Bytes { + return Ripemd160.make(data); + } + + public static function encodeSeed(material:Bytes):String { + var prefix = Bytes.ofHex(SEEDPREFIX); + var payload = Bytes.alloc(prefix.length + material.length); + payload.blit(0, prefix, 0, prefix.length); + payload.blit(prefix.length, material, 0, material.length); + return Base58Check.encode(payload); + } + + public static function encodeAddress(publicKey:Bytes):String { + var prefix = Bytes.ofHex(ADDRESSPREFIX); + var hash = hash(publicKey); + var ripemd = ripemd160(hash); + var payload = Bytes.alloc(prefix.length + ripemd.length); + payload.blit(0, prefix, 0, prefix.length); + payload.blit(prefix.length, ripemd, 0, ripemd.length); + return Base58Check.encode(payload); + } + + public static function toHex(bytes:Bytes):String { + return bytes.toHex(); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/LICENSE.md b/leenkx/Sources/leenkx/network/torrent/LICENSE.md new file mode 100644 index 00000000..069c7885 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Riccardo Mazzucco - All Rights reserved + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/leenkx/Sources/leenkx/network/torrent/LeenkxClient.hx b/leenkx/Sources/leenkx/network/torrent/LeenkxClient.hx new file mode 100644 index 00000000..6dbd9655 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/LeenkxClient.hx @@ -0,0 +1,523 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.Types; +import leenkx.network.torrent.Base58.Base58Check; +import leenkx.network.torrent.transport.ITransport; +import leenkx.network.torrent.transport.ITransport.TransportOpts; +import leenkx.network.torrent.TorrentInfo; + +class LeenkxClient { + + static var PEERTIMEOUT:Float = 5 * 60 * 1000; + + public var identifier:String; + public var seed:String; + public var pk:String; + public var ek:String; + public var timeout:Float; + public var announce:Array; + public var infoHashBinary:String; + public var peerIdBinary:String; + + public var keyPair:{publicKey:Bytes, secretKey:Bytes}; + public var keyPairEncrypt:{publicKey:Bytes, secretKey:Bytes}; + + public var peers:Map = []; + public var seen:Map = []; + public var seenFired:Map = []; + public var pkToAddress:Map = []; + public var isRelay:Bool = false; + public var lastPeerCount:Null = null; + public var lastPeerId:String = null; + + public var serverAddress:String = null; + public var heartbeatTimer:Null = null; + + public var onOpen:Void -> Void; + public var onTorrent:String -> Dynamic -> Void; + public var onMessage:String -> Dynamic -> Dynamic -> Void; + public var onSeen:String -> Void; + public var onLeft:String -> Void; + public var onServer:String -> Void; + public var onPing:String -> Void; + public var onTimeout:String -> Void; + public var onConnections:Int -> Void; + public var onWireSeen:Int -> Dynamic -> Void; + public var onWireLeft:Int -> Dynamic -> Void; + public var onTracker:String -> Dynamic -> Void; + public var onAnnounce:String -> Void; + public var onError:String -> Void; + + public var transport:ITransport; + private var deferredOpts:TransportOpts = null; + private var heartbeatInterval:Int = 30000; + + public function new(?identifier:String, ?opts:LeenkxOptions) { + if (identifier != null && !Std.isOfType(identifier, String)) { + opts = cast identifier; + identifier = null; + } + opts = opts != null ? opts : {}; + + this.announce = opts.announce != null ? opts.announce : ["wss://ws1.leenkx.com"]; + this.timeout = opts.timeout != null ? opts.timeout : PEERTIMEOUT; + + if (opts.seed != null) { + this.seed = opts.seed; + } else { + this.seed = Crypto.encodeSeed(Crypto.randomBytes(32)); + } + + if (opts.keyPair != null) { + this.keyPair = opts.keyPair; + } else { + var seedBytes = Base58Check.decode(this.seed); + var material = seedBytes.sub(2, seedBytes.length - 2); + this.keyPair = Crypto.signKeyPairFromSeed(material); + } + + this.keyPairEncrypt = Crypto.boxKeyPair(); + + this.pk = Base58.encode(this.keyPair.publicKey); + this.ek = Base58.encode(this.keyPairEncrypt.publicKey); + + this.identifier = identifier != null ? identifier : this.address(); + + var infoHash = TorrentInfo.identifierHash(this.identifier); + this.infoHashBinary = TorrentInfo.toBinaryString(infoHash); + this.peerIdBinary = TorrentInfo.toBinaryString(TorrentInfo.peerId()); + + createTransport(opts, opts.deferJoin == true); + + this.heartbeatInterval = opts.heartbeat != null ? opts.heartbeat : 30000; + } + + function createTransport(opts:LeenkxOptions, defer:Bool):Void { + var transportOpts:TransportOpts = { + announce: this.announce, + iceServers: opts.iceServers, + tracker: opts.tracker, + wtOpts: opts.wtOpts, + torrentOpts: opts.torrentOpts, torrent: opts.torrent, + pk: this.pk, + ek: this.ek, + infoHashBinary: this.infoHashBinary, + peerIdBinary: this.peerIdBinary + }; + + var announceUrl = this.announce[0]; + if (opts.transport == "datachannel") { + transport = new leenkx.network.torrent.transport.DatachannelTransport(announceUrl, opts.iceServers); + } else { + transport = new leenkx.network.torrent.transport.WireTransport(announceUrl, opts.iceServers); + } + + transport.onReady = onTransportReady; + transport.onPeerConnected = onTransportPeerConnected; + transport.onPeerDisconnected = onTransportPeerDisconnected; + transport.onPeerData = onTransportPeerData; + transport.onPeerHandshake = onTransportPeerHandshake; + transport.onTrackerUpdate = onTransportTrackerUpdate; + transport.onTrackerAnnounce = onTransportTrackerAnnounce; + transport.onError = onTransportError; + + if (defer) { + deferredOpts = transportOpts; + } else { + transport.join(this.identifier, transportOpts); + } + } + + public function connect():Void { + if (deferredOpts != null) { + var opts = deferredOpts; + deferredOpts = null; + transport.join(this.identifier, opts); + } + this.heartbeat(this.heartbeatInterval); + } + + function onTransportReady():Void { + if (onTorrent != null) onTorrent(identifier, null); + } + + function onTransportPeerConnected(peerId:String, count:Int):Void { + lastPeerId = peerId; + var prevCount = lastPeerCount; + lastPeerCount = count; + if (onWireSeen != null) onWireSeen(count, {peerId: peerId}); + if (count != prevCount) { + if (onConnections != null) onConnections(count); + } + } + + function onTransportPeerDisconnected(count:Int):Void { + if (onWireLeft != null) onWireLeft(count, {peerId: lastPeerId}); + if (count != lastPeerCount) { + lastPeerCount = count; + if (onConnections != null) onConnections(count); + } + } + + function onTransportPeerData(senderPk:String, data:Bytes):Void { + onMessageData(identifier, data, senderPk); + } + + function onTransportPeerHandshake(peerPk:String, peerEk:String):Void { + if (peerPk != null && peerPk != "") { + sawPeer(peerPk, peerEk, identifier); + } + } + + function onTransportTrackerUpdate(update:Dynamic):Void { + if (onTracker != null) onTracker(identifier, update); + } + + function onTransportTrackerAnnounce():Void { + if (onAnnounce != null) onAnnounce(identifier); + } + + function onTransportError(msg:String):Void { + trace("onTransportError: " + msg); + if (onError != null) onError(msg); + } + + public function address(?pk:Dynamic):String { + var pkBytes:Bytes = null; + if (pk != null && Std.isOfType(pk, String)) { + pkBytes = Base58.decode(pk); + } else if (pk != null) { + pkBytes = pk; + } else { + pkBytes = this.keyPair.publicKey; + } + return Crypto.encodeAddress(pkBytes); + } + + function addressFromPk(pkStr:String):String { + var cached = pkToAddress.get(pkStr); + if (cached != null) return cached; + var addr = address(Base58.decode(pkStr)); + pkToAddress.set(pkStr, addr); + return addr; + } + + public function encodeSeed(material:Bytes):String { + return Crypto.encodeSeed(material); + } + + public function ping():Void { + var packet = makePacket({y: PacketType.Ping}); + sendRaw(packet); + } + + public function send(?address:String, message:Dynamic):Void { + if (message == null) { + message = address; + address = null; + } + var packet = makePacket({ + y: PacketType.Message, + v: haxe.Json.stringify(message) + }); + var peerPk:String = null; + if (address != null) { + if (peers.exists(address)) { + peerPk = peers[address].pk; + packet = encryptPacket(peerPk, packet); + } else { + throw address + " not seen - no public key."; + } + } + sendRaw(packet, peerPk); + } + + public function sendRawMessage(data:Bytes, ?address:String):Void { + var marked = Bytes.alloc(data.length + 1); + marked.set(0, 0x00); + marked.blit(1, data, 0, data.length); + if (transport != null) { + if (address != null) { + if (!peers.exists(address)) { + throw address + " not seen - no public key."; + } + if (!transport.sendTo(peers[address].pk, marked)) { + trace("sendRawMessage: no open channel to " + address); + } + } else { + transport.broadcast(marked); + } + } + } + + public function heartbeat(?interval:Int):Void { + interval = interval != null ? interval : 30000; + if (heartbeatTimer != null) heartbeatTimer.stop(); + heartbeatTimer = new haxe.Timer(interval); + heartbeatTimer.run = () -> { + ping(); + var t = now(); + for (p in [for (k in peers.keys()) k]) { + var peerInfo = peers[p]; + if (peerInfo.last + interval < t) { + peers.remove(p); + if (onTimeout != null) onTimeout(p); + if (onLeft != null) onLeft(p); + } + } + for (h in [for (k in seen.keys()) k]) { + if (seen.get(h) + interval < t) { + seen.remove(h); + } + } + }; + } + + public function destroy(?cb:Void -> Void):Void { + if (heartbeatTimer != null) { + heartbeatTimer.stop(); + heartbeatTimer = null; + } + var packet = makePacket({y: PacketType.Leave}); + sendRaw(packet); + if (transport != null) { + transport.destroy(cb); + } else if (cb != null) { + cb(); + } + } + + public function close(?cb:Void -> Void):Void { + destroy(cb); + } + + public function connections():Int { + if (transport != null) { + var count = transport.getPeerCount(); + if (count != lastPeerCount) { + lastPeerCount = count; + if (onConnections != null) onConnections(count); + } + return count; + } + return 0; + } + + function makePacket(params:Dynamic):Bytes { + var p:Dynamic = { + t: now(), + i: identifier, + pk: pk, + ek: ek, + n: Crypto.randomBytes(8) + }; + for (k in Reflect.fields(params)) { + Reflect.setField(p, k, Reflect.field(params, k)); + } + var pe = Bencode.encode(p); + var sig = Crypto.signDetached(pe, keyPair.secretKey); + return Bencode.encode({s: sig, p: pe}); + } + + function encryptPacket(peerPk:String, packet:Bytes):Bytes { + var addr = addressFromPk(peerPk); + if (peers.exists(addr)) { + var nonce = Crypto.randomBytes(Crypto.boxNonceLength); + var encrypted = Crypto.box( + packet, + nonce, + Base58.decode(peers[addr].ek), + keyPairEncrypt.secretKey + ); + return Bencode.encode({ + n: nonce, + ek: Base58.encode(keyPairEncrypt.publicKey), + e: encrypted + }); + } else { + throw addressFromPk(peerPk) + " not seen - no encryption key."; + } + } + + function sendRaw(message:Bytes, ?peerPk:String):Void { + var hash = Crypto.hash(message).sub(0, 16).toHex(); + seen.set(hash, now()); + if (transport != null) { + if (peerPk != null) { + if (!transport.sendTo(peerPk, message)) { + transport.broadcast(message); + } + } else { + transport.broadcast(message); + } + } + } + + function onMessageData(identifier:String, message:Bytes, ?senderPk:String):Void { + var hash = Crypto.hash(message).sub(0, 16).toHex(); + var t = now(); + if (!seen.exists(hash)) { + if (message.length > 0 && (message.get(0) == 0x00 || message.get(0) == 0x01)) { + var isRelayed = message.get(0) == 0x01; + var offset = 1; + var msgSenderPk:String = senderPk; + if (isRelayed) { + if (message.length < 2) { + seen.set(hash, t); + return; + } + var pkLen = message.get(1); + if (2 + pkLen > message.length) { + seen.set(hash, t); + return; + } + var claimedPk = message.sub(2, pkLen).toString(); + if (pkToAddress.exists(claimedPk)) { + msgSenderPk = claimedPk; + } + offset = 2 + pkLen; + } + var rawPayload = message.sub(offset, message.length - offset); + var payload = new leenkx.network.Buffer(); + payload.writeBytes(rawPayload); + seen.set(hash, t); + var senderAddr = msgSenderPk != null ? pkToAddress.get(msgSenderPk) : null; + if (senderAddr == null && msgSenderPk != null && msgSenderPk != "") { + senderAddr = addressFromPk(msgSenderPk); + } + if (onMessage != null) { + onMessage(senderAddr, payload, null); + } + if (isRelay && transport != null && transport.getPeerCount() > 1) { + if (isRelayed) { + sendRaw(message); + } else if (msgSenderPk != null && msgSenderPk != "") { + var pkBytes = Bytes.ofString(msgSenderPk); + var relayed = Bytes.alloc(1 + 1 + pkBytes.length + rawPayload.length); + relayed.set(0, 0x01); + relayed.set(1, pkBytes.length); + relayed.blit(2, pkBytes, 0, pkBytes.length); + relayed.blit(2 + pkBytes.length, rawPayload, 0, rawPayload.length); + sendRaw(relayed); + } + } + return; + } + + var unpacked:Dynamic = null; + try { + unpacked = Bencode.decode(message); + } catch(e:Dynamic) { + trace("Bencode decode error: " + e); + seen.set(hash, t); + return; + } + + try { + var isEncrypted = Reflect.field(unpacked, "e") != null + && Reflect.field(unpacked, "n") != null + && Reflect.field(unpacked, "ek") != null; + + if (isEncrypted) { + var ekStr:String = unpacked.ek.toString(); + var decrypted = Crypto.boxOpen( + unpacked.e, + unpacked.n, + Base58.decode(ekStr), + keyPairEncrypt.secretKey + ); + if (decrypted != null) { + unpacked = Bencode.decode(decrypted); + } else { + unpacked = null; + } + } + + if (unpacked != null && unpacked.p != null) { + var packet:Dynamic = Bencode.decode(unpacked.p); + var pkStr:String = packet.pk.toString(); + var idStr:String = packet.i.toString(); + var checkSig = Crypto.signDetachedVerify(unpacked.p, unpacked.s, Base58.decode(pkStr)); + var checkId = idStr == identifier; + var checkTime = packet.t + timeout > t; + + if (checkSig && checkId && checkTime) { + var ekStr:String = packet.ek.toString(); + sawPeer(pkStr, ekStr, identifier); + + var yStr:String = packet.y != null ? packet.y.toString() : null; + if (yStr == PacketType.Message) { + var msgStr:String = packet.v.toString(); + var msgJson:Dynamic = null; + try { + msgJson = haxe.Json.parse(msgStr); + } catch(e) { + } + if (msgJson != null) { + if (onMessage != null) { + onMessage(addressFromPk(pkStr), msgJson, packet); + } + } else { + try { + var payload = new leenkx.network.Buffer(); + payload.writeBytes( + haxe.crypto.Base64.decode(msgStr)); + if (onMessage != null) { + onMessage(addressFromPk(pkStr), payload, packet); + } + } catch(e:Dynamic) {} + } + } else if (yStr == PacketType.Ping) { + if (onPing != null) onPing(addressFromPk(pkStr)); + } else if (yStr == PacketType.Leave) { + var addr = addressFromPk(pkStr); + peers.remove(addr); + if (onLeft != null) onLeft(addr); + } + } + } + + if (transport != null && transport.getPeerCount() > 1) { + sendRaw(message); + } + } catch(e:Dynamic) { + trace("onMessageData error processing packet: " + e); + } + } + seen.set(hash, t); + } + + function sawPeer(pk:String, ek:String, identifier:String):Void { + var t = now(); + var addr = addressFromPk(pk); + var myAddr = address(); + var isSelf = (addr == myAddr); + var alreadySeen = seenFired.exists(addr); + if (!isSelf) { + if (!peers.exists(addr) || peers[addr].last + timeout < t) { + peers.set(addr, {pk: pk, ek: ek, last: t}); + var packet = makePacket({y: PacketType.Ping}); + sendRaw(packet); + } else { + var p = peers.get(addr); + p.ek = ek; + p.last = t; + } + if (!alreadySeen) { + seenFired.set(addr, true); + lastPeerId = addr; + if (onOpen != null) onOpen(); + if (onSeen != null) onSeen(addr); + if (addr == identifier) { + serverAddress = addr; + if (onServer != null) onServer(addr); + } + } + } + } + + static function now():Float { + return Date.now().getTime(); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Magnet.hx b/leenkx/Sources/leenkx/network/torrent/Magnet.hx new file mode 100644 index 00000000..4ce3ec3d --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Magnet.hx @@ -0,0 +1,115 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class Magnet { + public var infoHash:Bytes; + public var infoHashHex:String; + public var infoHashV2:Bytes; + public var infoHashV2Hex:String; + public var name:String = null; + public var trackers:Array = []; + public var webSeeds:Array = []; + + public function new() {} + + public static function parse(uri:String):Magnet { + if (uri.indexOf("magnet:?") != 0) return null; + var m = new Magnet(); + var query = uri.substr(8); + for (param in query.split("&")) { + var eq = param.indexOf("="); + if (eq < 0) continue; + var key = param.substr(0, eq); + var value = StringTools.urlDecode(param.substr(eq + 1)); + switch (key) { + case "xt": + if (value.indexOf("urn:btih:") == 0) { + var hash = value.substr(9); + m.infoHash = decodeBtih(hash); + if (m.infoHash != null) { + m.infoHashHex = Crypto.toHex(m.infoHash); + } + } else if (value.indexOf("urn:btmh:") == 0) { + var hash = value.substr(9); + if (hash.length == 68) hash = hash.substr(4); + if (hash.length == 64) { + try { + m.infoHashV2 = Bytes.ofHex(hash); + } catch(e:Dynamic) {} + if (m.infoHashV2 != null) { + m.infoHashV2Hex = + Crypto.toHex(m.infoHashV2); + } + } + } + case "dn": + m.name = value; + case "tr": + m.trackers.push(value); + case "ws": + m.webSeeds.push(value); + default: + } + } + if (m.infoHash == null && m.infoHashV2 == null) return null; + return m; + } + + public static function build(infoHash:Bytes, name:String, trackers:Array, ?webSeeds:Array, ?infoHashV2:Bytes):String { + var parts:Array = []; + if (infoHash != null) { + parts.push("xt=urn:btih:" + Crypto.toHex(infoHash)); + } + if (infoHashV2 != null) { + parts.push("xt=urn:btmh:1220" + + Crypto.toHex(infoHashV2)); + } + if (name != null) { + parts.push("dn=" + StringTools.urlEncode(name)); + } + if (trackers != null) { + for (t in trackers) { + parts.push("tr=" + StringTools.urlEncode(t)); + } + } + if (webSeeds != null) { + for (w in webSeeds) { + parts.push("ws=" + StringTools.urlEncode(w)); + } + } + return "magnet:?" + parts.join("&"); + } + + static function decodeBtih(s:String):Bytes { + if (s.length == 40) { + try { + return Bytes.ofHex(s); + } catch(e:Dynamic) { + return null; + } + } + if (s.length == 32) { + return base32Decode(s.toUpperCase()); + } + return null; + } + + static function base32Decode(s:String):Bytes { + var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + var out = new haxe.io.BytesBuffer(); + var buffer = 0; + var bits = 0; + for (i in 0...s.length) { + var v = alphabet.indexOf(s.charAt(i)); + if (v < 0) return null; + buffer = (buffer << 5) | v; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.addByte((buffer >> bits) & 0xFF); + } + } + return out.getBytes(); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/MetaInfo.hx b/leenkx/Sources/leenkx/network/torrent/MetaInfo.hx new file mode 100644 index 00000000..494a7925 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/MetaInfo.hx @@ -0,0 +1,711 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +typedef TorrentFileEntry = { + var path:String; + var length:Int; + var offset:Int; + @:optional var piecesRoot:Bytes; +} + +class MetaInfo { + public var infoHash:Bytes; + public var infoHashHex:String; + public var infoHashV2:Bytes = null; + public var infoHashV2Hex:String = null; + public var metaVersion:Int = 1; + public var name:String; + public var pieceLength:Int; + public var pieces:Bytes; + public var files:Array; + public var totalLength:Int = 0; + public var announce:Array; + public var rawInfo:Bytes; + public var isPrivate:Bool = false; + public var comment:String = null; + public var createdBy:String = null; + public var creationDate:Int = 0; + public var urlList:Array = null; + public var source:String = null; + public var fileHashes:Array> = null; + public var storageLength:Int = 0; + public var pieceLayers:Array<{root:Bytes, hashes:Bytes}> = null; + + public var numPieces(get, never):Int; + function get_numPieces():Int { + if (pieces != null && pieces.length > 0) { + return Std.int(pieces.length / TorrentInfo.HASH_LENGTH); + } + if (pieceLength > 0 && files != null) { + var end = 0; + for (f in files) { + var e = f.offset + f.length; + if (e > end) end = e; + } + return end > 0 + ? Std.int(Math.ceil(end / pieceLength)) : 0; + } + return 0; + } + + public var protocolVersion(get, never):Int; + function get_protocolVersion():Int { + return metaVersion == 2 ? (pieces != null ? 3 : 2) : 1; + } + + public function new() {} + + public static function fromTorrentBytes(data:Bytes):MetaInfo { + var dict:Dynamic = Bencode.decode(data); + if (dict == null || Reflect.field(dict, "info") == null) { + throw "MetaInfo: no info dict"; + } + var m = new MetaInfo(); + m.rawInfo = extractInfoBytes(data); + var info = Reflect.field(dict, "info"); + m.parseInfo(info); + if (m.metaVersion == 2) { + m.infoHashV2 = haxe.crypto.Sha256.make(m.rawInfo); + m.infoHashV2Hex = Crypto.toHex(m.infoHashV2); + } + if (m.pieces != null) { + m.infoHash = haxe.crypto.Sha1.make(m.rawInfo); + m.infoHashHex = Crypto.toHex(m.infoHash); + } + m.announce = parseAnnounce(dict); + m.initFileHashes(); + m.loadPieceLayers(data); + m.comment = bytesToString(Reflect.field(dict, "comment")); + m.createdBy = bytesToString(Reflect.field(dict, "created by")); + m.creationDate = Std.int(fieldFloat(dict, "creation date")); + var ul = Reflect.field(dict, "url-list"); + if (Std.isOfType(ul, Array)) { + m.urlList = [for (u in (ul:Array)) + bytesToString(u)]; + } else if (ul != null) { + m.urlList = [bytesToString(ul)]; + } + return m; + } + + function parseInfo(info:Dynamic):Void { + name = bytesToString(Reflect.field(info, "name")); + if (name == null) name = ""; + pieceLength = Std.int(fieldFloat(info, "piece length")); + metaVersion = Std.int(fieldFloat(info, "meta version")); + if (metaVersion == 0) metaVersion = 1; + var piecesField = Reflect.field(info, "pieces"); + if (piecesField == null) { + pieces = null; + } else { + pieces = Std.isOfType(piecesField, Bytes) ? piecesField + : Bytes.ofString(Std.string(piecesField)); + } + isPrivate = fieldFloat(info, "private") == 1; + source = bytesToString(Reflect.field(info, "source")); + + files = []; + totalLength = 0; + var fileTree = Reflect.field(info, "file tree"); + if (fileTree != null) { + walkFileTree(fileTree, [name]); + if (pieces != null) { + applyV1Layout(Reflect.field(info, "files")); + } + storageLength = pieces == null + ? treeCursor * pieceLength : totalLength; + return; + } + storageLength = 0; + var filesList:Array = Reflect.field(info, "files"); + if (filesList != null) { + for (f in filesList) { + var len = Std.int(fieldFloat(f, "length")); + var pathList:Array = Reflect.field(f, "path"); + var path = name; + if (pathList != null) { + var parts = [for (p in pathList) bytesToString(p)]; + path = name + "/" + parts.join("/"); + } + files.push({path: sanitizePath(path), length: len, + offset: totalLength}); + totalLength += len; + } + } else { + var len = Std.int(fieldFloat(info, "length")); + files.push({path: sanitizePath(name), length: len, + offset: 0}); + totalLength = len; + } + storageLength = totalLength; + } + + public static function sanitizeComponent(c:String):String { + if (c == null || c == "" || c == "." || c == ".." + || c.indexOf("/") >= 0 || c.indexOf("\\") >= 0 + || c.indexOf(":") >= 0 + || c.indexOf(String.fromCharCode(0)) >= 0) { + return "_"; + } + return c; + } + + public static function sanitizePath(p:String):String { + if (p == null) return "_"; + var parts = p.split("/"); + return [for (c in parts) sanitizeComponent(c)].join("/"); + } + + public function fileForPiece(index:Int):Int { + for (i in 0...files.length) { + var f = files[i]; + var start = Std.int(f.offset / pieceLength); + var np = Std.int(Math.ceil(f.length / pieceLength)); + if (index >= start && index < start + np) return i; + } + return -1; + } + + public function pieceSizeV2(index:Int):Int { + var fi = fileForPiece(index); + if (fi < 0) return 0; + var f = files[fi]; + var start = Std.int(f.offset / pieceLength); + var rem = f.length - (index - start) * pieceLength; + return Std.int(Math.min(pieceLength, rem)); + } + + public function verifyPieceV2(index:Int, data:Bytes):Bool { + var fi = fileForPiece(index); + if (fi < 0 || fileHashes == null + || fileHashes.length <= fi || fileHashes[fi] == null) { + return false; + } + var f = files[fi]; + var start = Std.int(f.offset / pieceLength); + var expected = fileHashes[fi][index - start]; + if (expected == null) return false; + var actual = f.length <= pieceLength + ? merkleRoot(data) : pieceRootV2(data); + return memcmp(actual, expected); + } + + public function pieceRootV2(data:Bytes):Bytes { + var base = Std.int(Math.log(pieceLength / 16384) + / Math.log(2)); + var pieceLeaves = 1 << base; + var leaves:Array = []; + var pos = 0; + while (pos < data.length) { + var n = Std.int(Math.min(16384, data.length - pos)); + leaves.push(haxe.crypto.Sha256.make(data.sub(pos, n))); + pos += n; + } + var zero = leafZero(); + while (leaves.length < pieceLeaves) leaves.push(zero); + return merkleReduce(leaves); + } + + public function verifyLayer(fi:Int, hashes:Array):Bool { + if (fi < 0 || hashes == null) return false; + var root = files[fi].piecesRoot; + if (root == null) return false; + var base = Std.int(Math.log(pieceLength / 16384) + / Math.log(2)); + var level = hashes.copy(); + var pow = 1; + while (pow < level.length) pow <<= 1; + var zero = layerZero(base); + while (level.length < pow) level.push(zero); + return memcmp(merkleReduce(level), root); + } + + static var layerZeroCache:Map = []; + public static function layerZero(level:Int):Bytes { + if (level == 0) return leafZero(); + var half = layerZero(level - 1); + return hashPair(half, half); + } + + public static function hashPair(a:Bytes, b:Bytes):Bytes { + var buf = new haxe.io.BytesBuffer(); + buf.addBytes(a, 0, 32); + buf.addBytes(b, 0, 32); + return haxe.crypto.Sha256.make(buf.getBytes()); + } + + public static function merkleReduce(hashes:Array):Bytes { + var level = hashes; + while (level.length > 1) { + var next:Array = []; + for (i in 0...Std.int(level.length / 2)) { + next.push(hashPair(level[i * 2], level[i * 2 + 1])); + } + level = next; + } + return level.length > 0 ? level[0] : Bytes.alloc(32); + } + + static public function memcmp(a:Bytes, b:Bytes):Bool { + if (a == null || b == null || a.length != b.length) return false; + for (i in 0...a.length) if (a.get(i) != b.get(i)) return false; + return true; + } + + var treeCursor:Int = 0; + function walkFileTree(node:Dynamic, path:Array):Void { + var aligned = pieces == null; + var keys = Reflect.fields(node); + keys.sort(Reflect.compare); + for (key in keys) { + var child = Reflect.field(node, key); + if (key == "") { + var len = Std.int(fieldFloat(child, "length")); + var p = path.length == 2 && path[0] == name + && path[1] == name ? name : path.join("/"); + var offset = aligned ? treeCursor * pieceLength + : totalLength; + files.push({path: sanitizePath(p), length: len, + offset: offset, + piecesRoot: toBytes( + Reflect.field(child, "pieces root"))}); + if (aligned) { + treeCursor += Std.int( + Math.ceil(len / pieceLength)); + } + totalLength += len; + } else { + var next = path.copy(); + next.push(key); + walkFileTree(child, next); + } + } + } + + function applyV1Layout(filesList:Dynamic):Void { + if (!Std.isOfType(filesList, Array)) return; + var byPath = new Map(); + for (f in files) byPath.set(f.path, f); + var ordered:Array = []; + var off = 0; + for (fl in (filesList:Array)) { + var len = Std.int(fieldFloat(fl, "length")); + var pathList:Array = Reflect.field(fl, "path"); + var path = name; + if (pathList != null) { + var parts = [for (p in pathList) bytesToString(p)]; + path = name + "/" + parts.join("/"); + } + var e = byPath.get(sanitizePath(path)); + if (e == null) { + e = {path: sanitizePath(path), length: len, + offset: off}; + } else { + e.offset = off; + e.length = len; + } + off += len; + ordered.push(e); + } + files = ordered; + totalLength = off; + } + + public function initFileHashes():Void { + if (metaVersion != 2 || files == null + || pieceLength <= 0) return; + if (fileHashes == null) { + fileHashes = [for (f in files) null]; + } + for (fi in 0...files.length) { + var f = files[fi]; + if (f.length > 0 && f.length <= pieceLength + && f.piecesRoot != null) { + fileHashes[fi] = [f.piecesRoot]; + } + } + } + + public function loadPieceLayers(data:Bytes):Void { + if (metaVersion != 2 || fileHashes == null) return; + var pos = findTopKey(data, "piece layers"); + if (pos < 0 || data.get(pos) != 'd'.code) return; + var byRoot = new Map(); + var p = pos + 1; + while (p < data.length && data.get(p) != 'e'.code) { + var key = rawString(data, p); + if (key == null) return; + p = skipValue(data, p); + var val = rawString(data, p); + if (val == null) return; + p = skipValue(data, p); + byRoot.set(Crypto.toHex(key), val); + } + for (fi in 0...files.length) { + var f = files[fi]; + if (f.piecesRoot == null) continue; + var np = Std.int(Math.ceil(f.length / pieceLength)); + if (np <= 1) continue; + var raw = byRoot.get(Crypto.toHex(f.piecesRoot)); + if (raw == null || raw.length < np * 32) continue; + var hashes = [for (i in 0...np) raw.sub(i * 32, 32)]; + if (verifyLayer(fi, hashes)) fileHashes[fi] = hashes; + } + } + + static function rawString(data:Bytes, pos:Int):Bytes { + var b = data.get(pos); + if (b < '0'.code || b > '9'.code) return null; + var len = 0; + while (pos < data.length && data.get(pos) != ':'.code) { + len = len * 10 + (data.get(pos) - '0'.code); + pos++; + } + if (pos + 1 + len > data.length) return null; + return data.sub(pos + 1, len); + } + + static function findTopKey(data:Bytes, want:String):Int { + if (data.length == 0 || data.get(0) != 'd'.code) return -1; + var pos = 1; + while (pos < data.length && data.get(pos) != 'e'.code) { + var keyEnd = skipValue(data, pos); + var key = data.sub(pos, keyEnd - pos).toString(); + var colon = key.indexOf(":"); + key = key.substr(colon + 1); + var valEnd = skipValue(data, keyEnd); + if (key == want) return keyEnd; + pos = valEnd; + } + return -1; + } + + static function parseAnnounce(dict:Dynamic):Array { + var out:Array = []; + var seen = new Map(); + var list:Array = Reflect.field(dict, "announce-list"); + if (list != null) { + for (tier in list) { + if (Std.isOfType(tier, Array)) { + for (u in (tier:Array)) { + var s = bytesToString(u); + if (s != null && !seen.exists(s)) { + seen.set(s, true); + out.push(s); + } + } + } + } + } + var single = bytesToString(Reflect.field(dict, "announce")); + if (single != null && !seen.exists(single)) { + out.push(single); + } + return out; + } + + public static function fromInfoDict(rawInfo:Bytes, infoHash:Bytes, ?announce:Array):MetaInfo { + var m = new MetaInfo(); + m.rawInfo = rawInfo; + m.infoHash = infoHash; + m.infoHashHex = infoHash != null ? Crypto.toHex(infoHash) : null; + var infoDict = Bencode.decode(rawInfo); + m.parseInfo(infoDict); + if (m.metaVersion == 2) { + m.infoHashV2 = haxe.crypto.Sha256.make(rawInfo); + m.infoHashV2Hex = Crypto.toHex(m.infoHashV2); + } + m.announce = announce != null ? announce : []; + m.initFileHashes(); + return m; + } + + public static function create(name:String, files:Array<{path:String, data:Bytes}>, ?announce:Array, ?opts:Dynamic):MetaInfo { + var m = new MetaInfo(); + m.name = name; + m.announce = announce != null ? announce : []; + m.files = []; + var pieceLengthOpt = 0; + if (opts != null) { + m.isPrivate = fieldFloat(opts, "private") == 1 + || Reflect.field(opts, "private") == true; + m.source = Reflect.field(opts, "source"); + m.comment = Reflect.field(opts, "comment"); + m.createdBy = Reflect.field(opts, "createdBy"); + var u:Dynamic = Reflect.field(opts, "urlList"); + if (u != null) { + m.urlList = Std.isOfType(u, Array) + ? (u:Array) : [Std.string(u)]; + } + pieceLengthOpt = Std.int(fieldFloat(opts, "pieceLength")); + } + + var mvOpt = opts != null + ? Std.int(fieldFloat(opts, "metaVersion")) : 1; + if (mvOpt < 1) mvOpt = 1; + var isV1 = mvOpt != 2; + var isV2 = mvOpt != 1; + m.metaVersion = isV2 ? 2 : 1; + + var aligned = mvOpt == 2; + var total = 0; + for (f in files) total += f.data.length; + m.pieceLength = pieceLengthOpt > 0 ? pieceLengthOpt + : TorrentInfo.pieceLength(total); + + var cursor = 0; + var content = new haxe.io.BytesBuffer(); + for (f in files) { + var entry:TorrentFileEntry = {path: f.path, + length: f.data.length, + offset: aligned ? cursor * m.pieceLength + : m.totalLength}; + if (isV2) entry.piecesRoot = merkleRoot(f.data); + m.files.push(entry); + m.totalLength += f.data.length; + cursor += Std.int(Math.ceil(f.data.length + / m.pieceLength)); + content.addBytes(f.data, 0, f.data.length); + } + var all = content.getBytes(); + m.storageLength = aligned ? cursor * m.pieceLength + : m.totalLength; + m.pieces = isV1 ? TorrentInfo.pieces(all, m.pieceLength) + : null; + + var info:Dynamic = {}; + Reflect.setField(info, "name", name); + Reflect.setField(info, "piece length", m.pieceLength); + if (m.isPrivate) Reflect.setField(info, "private", 1); + if (m.source != null) Reflect.setField(info, "source", m.source); + if (isV1) { + Reflect.setField(info, "pieces", m.pieces); + if (files.length == 1) { + Reflect.setField(info, "length", files[0].data.length); + } else { + var fl:Array = []; + for (f in files) { + var fd:Dynamic = {}; + Reflect.setField(fd, "length", f.data.length); + var rel = f.path; + if (rel.indexOf(name + "/") == 0) { + rel = rel.substr(name.length + 1); + } + Reflect.setField(fd, "path", rel.split("/")); + fl.push(fd); + } + Reflect.setField(info, "files", fl); + } + } + if (isV2) { + Reflect.setField(info, "meta version", 2); + Reflect.setField(info, "file tree", + buildFileTree(name, files, m.files)); + } + if (isV2) { + m.pieceLayers = []; + for (i in 0...files.length) { + var f = files[i]; + var entry = m.files[i]; + if (f.data.length <= m.pieceLength + || entry.piecesRoot == null) continue; + var np = Std.int(Math.ceil(f.data.length + / m.pieceLength)); + var buf = new haxe.io.BytesBuffer(); + for (p in 0...np) { + var off = p * m.pieceLength; + var sz = Std.int(Math.min(m.pieceLength, + f.data.length - off)); + buf.addBytes(m.pieceRootV2( + f.data.sub(off, sz)), 0, 32); + } + m.pieceLayers.push({root: entry.piecesRoot, + hashes: buf.getBytes()}); + } + if (m.pieceLayers.length == 0) m.pieceLayers = null; + } + m.rawInfo = Bencode.encode(info); + if (isV1) { + m.infoHash = haxe.crypto.Sha1.make(m.rawInfo); + m.infoHashHex = Crypto.toHex(m.infoHash); + } + if (isV2) { + m.infoHashV2 = haxe.crypto.Sha256.make(m.rawInfo); + m.infoHashV2Hex = Crypto.toHex(m.infoHashV2); + } + return m; + } + + public static function merkleRoot(data:Bytes):Bytes { + if (data.length == 0) return Bytes.alloc(32); + var leaves:Array = []; + var pos = 0; + while (pos < data.length) { + var n = Std.int(Math.min(16384, data.length - pos)); + leaves.push(haxe.crypto.Sha256.make(data.sub(pos, n))); + pos += n; + } + var pow = 1; + while (pow < leaves.length) pow <<= 1; + var zero = leafZero(); + while (leaves.length < pow) leaves.push(zero); + return merkleReduce(leaves); + } + + static var leafZeroCache:Bytes = null; + static function leafZero():Bytes { + if (leafZeroCache == null) leafZeroCache = Bytes.alloc(32); + return leafZeroCache; + } + + static function buildFileTree(name:String, files:Array<{path:String, data:Bytes}>, entries:Array):Dynamic { + var root:Dynamic = {}; + for (i in 0...files.length) { + var rel = files[i].path; + if (rel.indexOf(name + "/") == 0) { + rel = rel.substr(name.length + 1); + } + var parts = rel.split("/"); + var node = root; + for (p in 0...parts.length - 1) { + var nxt = Reflect.field(node, parts[p]); + if (nxt == null) { + nxt = {}; + Reflect.setField(node, parts[p], nxt); + } + node = nxt; + } + var leaf:Dynamic = {}; + Reflect.setField(leaf, "length", files[i].data.length); + Reflect.setField(leaf, "pieces root", + entries[i].piecesRoot); + var leafWrap:Dynamic = {}; + Reflect.setField(leafWrap, "", leaf); + Reflect.setField(node, parts[parts.length - 1], leafWrap); + } + return root; + } + + public function toTorrentBytes(?extra:Dynamic):Bytes { + var dict:Dynamic = extra != null ? extra : {}; + Reflect.setField(dict, "info", Bencode.decode(rawInfo)); + if (announce.length > 0) { + Reflect.setField(dict, "announce", announce[0]); + var tiers:Array = [for (a in announce) [a]]; + Reflect.setField(dict, "announce-list", tiers); + } + if (comment != null) Reflect.setField(dict, "comment", comment); + if (createdBy != null) { + Reflect.setField(dict, "created by", createdBy); + } + if (urlList != null && urlList.length > 0) { + Reflect.setField(dict, "url-list", + urlList.length == 1 ? (urlList[0]:Dynamic) + : [for (u in urlList) u]); + } + Reflect.setField(dict, "creation date", + creationDate > 0 ? creationDate + : Std.int(Date.now().getTime() / 1000)); + if (pieceLayers != null && pieceLayers.length > 0) { + var pl = new Bencode.RawDict(); + var sorted = pieceLayers.copy(); + sorted.sort(function(a, b) { + var n = a.root.length < b.root.length + ? a.root.length : b.root.length; + for (i in 0...n) { + var d = a.root.get(i) - b.root.get(i); + if (d != 0) return d; + } + return a.root.length - b.root.length; + }); + for (e in sorted) { + pl.pairs.push({key: e.root, val: e.hashes}); + } + Reflect.setField(dict, "piece layers", pl); + } + return Bencode.encode(dict); + } + + public function pieceHash(index:Int):Bytes { + return pieces.sub(index * TorrentInfo.HASH_LENGTH, + TorrentInfo.HASH_LENGTH); + } + + public function pieceSize(index:Int):Int { + if (pieces == null) return pieceSizeV2(index); // v2 aligned + if (index == numPieces - 1) { + var rem = totalLength % pieceLength; + return rem == 0 ? pieceLength : rem; + } + return pieceLength; + } + + static function fieldFloat(obj:Dynamic, name:String):Float { + var v = Reflect.field(obj, name); + if (v == null) return 0; + if (Std.isOfType(v, Float) || Std.isOfType(v, Int)) return v; + return Std.parseFloat(Std.string(v)); + } + + static function bytesToString(v:Dynamic):String { + if (v == null) return null; + if (Std.isOfType(v, String)) return v; + if (Std.isOfType(v, Float) || Std.isOfType(v, Int)) { + return Std.string(v); + } + if (Std.isOfType(v, Bytes)) return (v:Bytes).toString(); + return Std.string(v); + } + + public static function toBytes(v:Dynamic):Bytes { + if (v == null) return null; + if (Std.isOfType(v, Float) || Std.isOfType(v, Int) + || Std.isOfType(v, String) || Std.isOfType(v, Array)) { + return null; + } + if (Std.isOfType(v, Bytes)) return (v:Bytes); + return null; + } + + static function extractInfoBytes(data:Bytes):Bytes { + var pos = 0; + if (data.get(pos) != 'd'.code) throw "MetaInfo: not a dict"; + pos++; + while (pos < data.length && data.get(pos) != 'e'.code) { + var keyEnd = skipValue(data, pos); + var key = data.sub(pos, keyEnd - pos).toString(); + var colon = key.indexOf(":"); + key = key.substr(colon + 1); + var valStart = keyEnd; + var valEnd = skipValue(data, valStart); + if (key == "info") { + return data.sub(valStart, valEnd - valStart); + } + pos = valEnd; + } + throw "MetaInfo: info key not found"; + } + + static public function skipValue(data:Bytes, pos:Int):Int { + var b = data.get(pos); + if (b == 'i'.code) { + pos++; + while (pos < data.length && data.get(pos) != 'e'.code) pos++; + return pos + 1; + } + if (b == 'l'.code || b == 'd'.code) { + pos++; + while (pos < data.length && data.get(pos) != 'e'.code) { + pos = skipValue(data, pos); + } + return pos + 1; + } + var len = 0; + while (pos < data.length && data.get(pos) != ':'.code) { + len = len * 10 + (data.get(pos) - '0'.code); + pos++; + } + return pos + 1 + len; + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/PieceManager.hx b/leenkx/Sources/leenkx/network/torrent/PieceManager.hx new file mode 100644 index 00000000..27edfbee --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/PieceManager.hx @@ -0,0 +1,329 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.storage.Storage.IStorage; + +class PieceManager { + public static inline var BLOCK_SIZE = 16384; + + var meta:MetaInfo; + var storage:IStorage; + + public var have:Array; + public var numHave:Int = 0; + public var bitfield:Bytes; + + var pending:Map> = []; + var pendingCount:Map = []; + var requested:Map>> = []; + var globalRequested:Map> = []; + var pendingV2:Map = []; + + public var onPieceComplete:Int -> Void; + public var onPieceFailed:Int -> Void; + public var onComplete:Void -> Void; + + @:allow(leenkx.network.torrent.Torrent) + @:allow(leenkx.network.torrent.WebSeed) + var selections:Array<{start:Int, end:Int, priority:Int, + notify:Void -> Void}> = []; + + public function select(startByte:Int, endByte:Int, ?priority:Int, ?notify:Void -> Void):Void { + selections.push({start: pieceIndex(startByte), + end: pieceIndex(endByte), + priority: priority != null ? priority : 0, + notify: notify}); + selections.sort(function(a, b) return b.priority + - a.priority); + } + + public function deselect(startByte:Int, endByte:Int, ?priority:Int):Void { + var s = pieceIndex(startByte); + var e = pieceIndex(endByte); + var p = priority != null ? priority : 0; + for (i in 0...selections.length) { + var sel = selections[i]; + if (sel.start == s && sel.end == e + && sel.priority == p) { + selections.splice(i, 1); + return; + } + } + } + + function pieceIndex(byte:Int):Int { + var i = Std.int(byte / meta.pieceLength); + if (i < 0) i = 0; + if (i >= meta.numPieces) i = meta.numPieces - 1; + return i; + } + + function updateSelections(index:Int):Void { + var i = 0; + while (i < selections.length) { + var sel = selections[i]; + if (index < sel.start || index > sel.end + || sel.notify == null) { + i++; + continue; + } + var done = true; + for (p in sel.start...sel.end + 1) { + if (!have[p]) { + done = false; + break; + } + } + if (done) { + selections.splice(i, 1); + sel.notify(); + } else i++; + } + } + + public var complete(get, never):Bool; + function get_complete():Bool return numHave == meta.numPieces; + + public function new(meta:MetaInfo, storage:IStorage) { + this.meta = meta; + this.storage = storage; + have = [for (i in 0...meta.numPieces) false]; + bitfield = Bytes.alloc(Std.int(Math.ceil(meta.numPieces / 8))); + } + + public static function forSeeder(meta:MetaInfo, storage:IStorage):PieceManager { + var pm = new PieceManager(meta, storage); + for (i in 0...meta.numPieces) pm.markHave(i); + return pm; + } + + public function markHave(index:Int):Void { + if (have[index]) return; + have[index] = true; + numHave++; + bitfield.set(index >> 3, bitfield.get(index >> 3) + | (0x80 >> (index & 7))); + } + + public function addBlock(index:Int, begin:Int, data:Bytes):Bool { + if (index < 0 || index >= meta.numPieces || begin < 0 + || data == null + || begin + data.length > meta.pieceSize(index)) { + return false; + } + if (have[index]) return true; + var blocks = pending.get(index); + if (blocks == null) { + blocks = []; + pending.set(index, blocks); + pendingCount.set(index, 0); + } + if (!blocks.exists(begin)) { + blocks.set(begin, data); + pendingCount.set(index, pendingCount.get(index) + data.length); + var gReq = globalRequested.get(index); + if (gReq != null) { + gReq.remove(begin); + if (!gReq.keys().hasNext()) globalRequested.remove(index); + } + } + if (pendingCount.get(index) < meta.pieceSize(index)) return false; + + var piece = Bytes.alloc(meta.pieceSize(index)); + var off = 0; + var keys = [for (k in blocks.keys()) k]; + keys.sort(Reflect.compare); + for (b in keys) { + var d = blocks.get(b); + piece.blit(b, d, 0, d.length); + } + var ok = true; + if (meta.pieces == null) { + var fi = meta.fileForPiece(index); + var layer = fi >= 0 && meta.fileHashes != null + && meta.fileHashes.length > fi + ? meta.fileHashes[fi] : null; + pending.remove(index); + pendingCount.remove(index); + if (layer == null) { + pendingV2.set(index, piece); + return false; + } + ok = meta.verifyPieceV2(index, piece); + } else { + var hash = haxe.crypto.Sha1.make(piece); + ok = MetaInfo.memcmp(hash, meta.pieceHash(index)); + pending.remove(index); + pendingCount.remove(index); + } + if (!ok) { + trace('[PieceManager] piece ' + index + + ' verification FAILED'); + releaseRequested(index); + if (onPieceFailed != null) onPieceFailed(index); + return false; + } + storage.write(index * meta.pieceLength, piece); + markHave(index); + updateSelections(index); + if (onPieceComplete != null) onPieceComplete(index); + if (complete && onComplete != null) onComplete(); + return true; + } + + public function flushPendingV2(fi:Int):Bool { + if (meta.pieces != null) return false; + var f = meta.files[fi]; + var start = Std.int(f.offset / meta.pieceLength); + var np = Std.int(Math.ceil(f.length / meta.pieceLength)); + var wrote = false; + for (i in start...start + np) { + var piece = pendingV2.get(i); + if (piece == null) continue; + pendingV2.remove(i); + if (!meta.verifyPieceV2(i, piece)) { + trace('[PieceManager] flush piece ' + i + + ' verification FAILED'); + releaseRequested(i); + if (onPieceFailed != null) onPieceFailed(i); + continue; + } + storage.write(i * meta.pieceLength, piece); + markHave(i); + updateSelections(i); + wrote = true; + if (onPieceComplete != null) onPieceComplete(i); + } + if (complete && onComplete != null) onComplete(); + return wrote; + } + + public function untouched(index:Int):Bool { + return !have[index] && !pending.exists(index) + && !pendingV2.exists(index) + && !globalRequested.exists(index); + } + + public function readBlock(index:Int, begin:Int, length:Int):Bytes { + return storage.read(index * meta.pieceLength + begin, length); + } + + public function neededBlocks(peerKey:String, index:Int):Array<{begin:Int, length:Int}> { + if (pendingV2.exists(index)) return []; + var size = meta.pieceSize(index); + var blocks = pending.get(index); + var peerReq = requested.get(peerKey); + var req = peerReq != null ? peerReq.get(index) : null; + var globalReq = globalRequested.get(index); + var out:Array<{begin:Int, length:Int}> = []; + var off = 0; + while (off < size) { + var len = size - off; + if (len > BLOCK_SIZE) len = BLOCK_SIZE; + if ((blocks == null || !blocks.exists(off)) + && (req == null || !req.exists(off)) + && (globalReq == null || !globalReq.exists(off))) { + out.push({begin: off, length: len}); + } + off += len; + } + return out; + } + + public function markRequested(key:String, index:Int, begin:Int):Void { + var r = requested.get(key); + if (r == null) requested.set(key, + r = new Map>()); + var m = r.get(index); + if (m == null) r.set(index, m = new Map()); + m.set(begin, true); + var g = globalRequested.get(index); + if (g == null) globalRequested.set(index, + g = new Map()); + g.set(begin, true); + reqTimes.set(key + "|" + index + "|" + begin, + haxe.Timer.stamp()); + } + + var reqTimes:Map = []; + + public function expireRequests(ttlSec:Float):Map { + var now = haxe.Timer.stamp(); + var dead:Array = []; + for (k in reqTimes.keys()) { + if (now - reqTimes.get(k) > ttlSec) dead.push(k); + } + var expired:Map = []; + for (k in dead) { + var p = k.split("|"); + var idx = Std.parseInt(p[1]); + var beg = Std.parseInt(p[2]); + var peerReq = requested.get(p[0]); + if (peerReq != null) { + var r = peerReq.get(idx); + if (r != null && r.exists(beg)) { + r.remove(beg); + var n = expired.exists(p[0]) ? expired.get(p[0]) : 0; + expired.set(p[0], n + 1); + } + } + var g = globalRequested.get(idx); + if (g != null) { + g.remove(beg); + var empty = true; + for (b in g.keys()) { + empty = false; + break; + } + if (empty) globalRequested.remove(idx); + } + reqTimes.remove(k); + } + return expired; + } + + public function releaseRequested(index:Int):Void { + globalRequested.remove(index); + var keys = [for (k in requested.keys()) k]; + for (key in keys) { + var r = requested.get(key); + var m = r.get(index); + if (m != null) { + for (b in m.keys()) { + reqTimes.remove(key + "|" + index + "|" + b); + } + r.remove(index); + } + } + } + + public function clearRequested(peerKey:String, index:Int):Void { + var peerReq = requested.get(peerKey); + if (peerReq != null) { + var req = peerReq.get(index); + if (req != null) { + var gReq = globalRequested.get(index); + if (gReq != null) { + for (off in req.keys()) gReq.remove(off); + if (!gReq.keys().hasNext()) globalRequested.remove(index); + } + } + peerReq.remove(index); + } + } + + public function clearAllRequested(peerKey:String):Void { + var peerReq = requested.get(peerKey); + if (peerReq != null) { + for (index in peerReq.keys()) { + var req = peerReq.get(index); + var gReq = globalRequested.get(index); + if (gReq != null && req != null) { + for (off in req.keys()) gReq.remove(off); + if (!gReq.keys().hasNext()) globalRequested.remove(index); + } + } + requested.remove(peerKey); + } + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Ripemd160.hx b/leenkx/Sources/leenkx/network/torrent/Ripemd160.hx new file mode 100644 index 00000000..94859d01 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Ripemd160.hx @@ -0,0 +1,334 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class Ripemd160 { + + static inline function ROL(x:Int, n:Int):Int { + return (x << n) | (x >>> (32 - n)); + } + + static inline function F(x:Int, y:Int, z:Int):Int { + return x ^ y ^ z; + } + + static inline function G(x:Int, y:Int, z:Int):Int { + return (x & y) | (~x & z); + } + + static inline function H(x:Int, y:Int, z:Int):Int { + return (x | ~y) ^ z; + } + + static inline function I(x:Int, y:Int, z:Int):Int { + return (x & z) | (y & ~z); + } + + static inline function J(x:Int, y:Int, z:Int):Int { + return x ^ (y | ~z); + } + + static inline function FF(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + F(b, c, d) + x; + a = ROL(a, s) + e; + return a; + } + + static inline function GG(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + G(b, c, d) + x + 0x5A827999; + a = ROL(a, s) + e; + return a; + } + + static inline function HH(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + H(b, c, d) + x + 0x6ED9EBA1; + a = ROL(a, s) + e; + return a; + } + + static inline function II(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + I(b, c, d) + x + 0x8F1BBCDC; + a = ROL(a, s) + e; + return a; + } + + static inline function JJ(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + J(b, c, d) + x + 0xA953FD4E; + a = ROL(a, s) + e; + return a; + } + + static inline function FFF(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + F(b, c, d) + x; + a = ROL(a, s) + e; + return a; + } + + static inline function GGG(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + G(b, c, d) + x + 0x7A6D76E9; + a = ROL(a, s) + e; + return a; + } + + static inline function HHH(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + H(b, c, d) + x + 0x6D703EF3; + a = ROL(a, s) + e; + return a; + } + + static inline function III(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + I(b, c, d) + x + 0x5C4DD124; + a = ROL(a, s) + e; + return a; + } + + static inline function JJJ(a:Int, b:Int, c:Int, d:Int, e:Int, x:Int, s:Int):Int { + a = a + J(b, c, d) + x + 0x50A28BE6; + a = ROL(a, s) + e; + return a; + } + + public static function make(data:Bytes):Bytes { + var msgLen = data.length; + var bitLen = msgLen * 8; + + var padLen = 56 - ((msgLen + 1) % 64); + if (padLen < 0) padLen += 64; + var totalLen = msgLen + 1 + padLen + 8; + + var padded = Bytes.alloc(totalLen); + padded.blit(0, data, 0, msgLen); + padded.set(msgLen, 0x80); + for (i in (msgLen + 1)...(totalLen - 8)) padded.set(i, 0); + padded.set(totalLen - 8, bitLen & 0xFF); + padded.set(totalLen - 7, (bitLen >> 8) & 0xFF); + padded.set(totalLen - 6, (bitLen >> 16) & 0xFF); + padded.set(totalLen - 5, (bitLen >> 24) & 0xFF); + padded.set(totalLen - 4, 0); + padded.set(totalLen - 3, 0); + padded.set(totalLen - 2, 0); + padded.set(totalLen - 1, 0); + + var h0 = 0x67452301; + var h1 = 0xEFCDAB89; + var h2 = 0x98BADCFE; + var h3 = 0x10325476; + var h4 = 0xC3D2E1F0; + + var numBlocks = Std.int(totalLen / 64); + + for (block in 0...numBlocks) { + var base = block * 64; + var X = new Array(); + for (i in 0...16) { + var off = base + i * 4; + X.push(padded.get(off) | (padded.get(off + 1) << 8) | + (padded.get(off + 2) << 16) | (padded.get(off + 3) << 24)); + } + + var a = h0, b = h1, c = h2, d = h3, e = h4; + var a1 = h0, b1 = h1, c1 = h2, d1 = h3, e1 = h4; + + a = FF(a, b, c, d, e, X[0], 11); c = ROL(c, 10); + e = FF(e, a, b, c, d, X[1], 14); b = ROL(b, 10); + d = FF(d, e, a, b, c, X[2], 15); a = ROL(a, 10); + c = FF(c, d, e, a, b, X[3], 12); e = ROL(e, 10); + b = FF(b, c, d, e, a, X[4], 5); d = ROL(d, 10); + a = FF(a, b, c, d, e, X[5], 8); c = ROL(c, 10); + e = FF(e, a, b, c, d, X[6], 7); b = ROL(b, 10); + d = FF(d, e, a, b, c, X[7], 9); a = ROL(a, 10); + c = FF(c, d, e, a, b, X[8], 11); e = ROL(e, 10); + b = FF(b, c, d, e, a, X[9], 13); d = ROL(d, 10); + a = FF(a, b, c, d, e, X[10], 14); c = ROL(c, 10); + e = FF(e, a, b, c, d, X[11], 15); b = ROL(b, 10); + d = FF(d, e, a, b, c, X[12], 6); a = ROL(a, 10); + c = FF(c, d, e, a, b, X[13], 7); e = ROL(e, 10); + b = FF(b, c, d, e, a, X[14], 9); d = ROL(d, 10); + a = FF(a, b, c, d, e, X[15], 8); c = ROL(c, 10); + + e = GG(e, a, b, c, d, X[7], 7); b = ROL(b, 10); + d = GG(d, e, a, b, c, X[4], 6); a = ROL(a, 10); + c = GG(c, d, e, a, b, X[13], 8); e = ROL(e, 10); + b = GG(b, c, d, e, a, X[1], 13); d = ROL(d, 10); + a = GG(a, b, c, d, e, X[10], 11); c = ROL(c, 10); + e = GG(e, a, b, c, d, X[6], 9); b = ROL(b, 10); + d = GG(d, e, a, b, c, X[15], 7); a = ROL(a, 10); + c = GG(c, d, e, a, b, X[3], 15); e = ROL(e, 10); + b = GG(b, c, d, e, a, X[12], 7); d = ROL(d, 10); + a = GG(a, b, c, d, e, X[0], 12); c = ROL(c, 10); + e = GG(e, a, b, c, d, X[9], 15); b = ROL(b, 10); + d = GG(d, e, a, b, c, X[5], 9); a = ROL(a, 10); + c = GG(c, d, e, a, b, X[2], 11); e = ROL(e, 10); + b = GG(b, c, d, e, a, X[14], 7); d = ROL(d, 10); + a = GG(a, b, c, d, e, X[11], 13); c = ROL(c, 10); + e = GG(e, a, b, c, d, X[8], 12); b = ROL(b, 10); + + d = HH(d, e, a, b, c, X[3], 11); a = ROL(a, 10); + c = HH(c, d, e, a, b, X[10], 13); e = ROL(e, 10); + b = HH(b, c, d, e, a, X[14], 6); d = ROL(d, 10); + a = HH(a, b, c, d, e, X[4], 7); c = ROL(c, 10); + e = HH(e, a, b, c, d, X[9], 14); b = ROL(b, 10); + d = HH(d, e, a, b, c, X[15], 9); a = ROL(a, 10); + c = HH(c, d, e, a, b, X[8], 13); e = ROL(e, 10); + b = HH(b, c, d, e, a, X[1], 15); d = ROL(d, 10); + a = HH(a, b, c, d, e, X[2], 14); c = ROL(c, 10); + e = HH(e, a, b, c, d, X[7], 8); b = ROL(b, 10); + d = HH(d, e, a, b, c, X[0], 13); a = ROL(a, 10); + c = HH(c, d, e, a, b, X[6], 6); e = ROL(e, 10); + b = HH(b, c, d, e, a, X[13], 5); d = ROL(d, 10); + a = HH(a, b, c, d, e, X[11], 12); c = ROL(c, 10); + e = HH(e, a, b, c, d, X[5], 7); b = ROL(b, 10); + d = HH(d, e, a, b, c, X[12], 5); a = ROL(a, 10); + + c = II(c, d, e, a, b, X[1], 11); e = ROL(e, 10); + b = II(b, c, d, e, a, X[9], 12); d = ROL(d, 10); + a = II(a, b, c, d, e, X[11], 14); c = ROL(c, 10); + e = II(e, a, b, c, d, X[10], 15); b = ROL(b, 10); + d = II(d, e, a, b, c, X[0], 14); a = ROL(a, 10); + c = II(c, d, e, a, b, X[8], 15); e = ROL(e, 10); + b = II(b, c, d, e, a, X[12], 9); d = ROL(d, 10); + a = II(a, b, c, d, e, X[4], 8); c = ROL(c, 10); + e = II(e, a, b, c, d, X[13], 9); b = ROL(b, 10); + d = II(d, e, a, b, c, X[3], 14); a = ROL(a, 10); + c = II(c, d, e, a, b, X[7], 5); e = ROL(e, 10); + b = II(b, c, d, e, a, X[15], 6); d = ROL(d, 10); + a = II(a, b, c, d, e, X[14], 8); c = ROL(c, 10); + e = II(e, a, b, c, d, X[5], 6); b = ROL(b, 10); + d = II(d, e, a, b, c, X[6], 5); a = ROL(a, 10); + c = II(c, d, e, a, b, X[2], 12); e = ROL(e, 10); + + b = JJ(b, c, d, e, a, X[4], 9); d = ROL(d, 10); + a = JJ(a, b, c, d, e, X[0], 15); c = ROL(c, 10); + e = JJ(e, a, b, c, d, X[5], 5); b = ROL(b, 10); + d = JJ(d, e, a, b, c, X[9], 11); a = ROL(a, 10); + c = JJ(c, d, e, a, b, X[7], 6); e = ROL(e, 10); + b = JJ(b, c, d, e, a, X[12], 8); d = ROL(d, 10); + a = JJ(a, b, c, d, e, X[2], 13); c = ROL(c, 10); + e = JJ(e, a, b, c, d, X[10], 12); b = ROL(b, 10); + d = JJ(d, e, a, b, c, X[14], 5); a = ROL(a, 10); + c = JJ(c, d, e, a, b, X[1], 12); e = ROL(e, 10); + b = JJ(b, c, d, e, a, X[3], 13); d = ROL(d, 10); + a = JJ(a, b, c, d, e, X[8], 14); c = ROL(c, 10); + e = JJ(e, a, b, c, d, X[11], 11); b = ROL(b, 10); + d = JJ(d, e, a, b, c, X[6], 8); a = ROL(a, 10); + c = JJ(c, d, e, a, b, X[15], 5); e = ROL(e, 10); + b = JJ(b, c, d, e, a, X[13], 6); d = ROL(d, 10); + + a1 = JJJ(a1, b1, c1, d1, e1, X[5], 8); c1 = ROL(c1, 10); + e1 = JJJ(e1, a1, b1, c1, d1, X[14], 9); b1 = ROL(b1, 10); + d1 = JJJ(d1, e1, a1, b1, c1, X[7], 9); a1 = ROL(a1, 10); + c1 = JJJ(c1, d1, e1, a1, b1, X[0], 11); e1 = ROL(e1, 10); + b1 = JJJ(b1, c1, d1, e1, a1, X[9], 13); d1 = ROL(d1, 10); + a1 = JJJ(a1, b1, c1, d1, e1, X[2], 15); c1 = ROL(c1, 10); + e1 = JJJ(e1, a1, b1, c1, d1, X[11], 15); b1 = ROL(b1, 10); + d1 = JJJ(d1, e1, a1, b1, c1, X[4], 5); a1 = ROL(a1, 10); + c1 = JJJ(c1, d1, e1, a1, b1, X[13], 7); e1 = ROL(e1, 10); + b1 = JJJ(b1, c1, d1, e1, a1, X[6], 7); d1 = ROL(d1, 10); + a1 = JJJ(a1, b1, c1, d1, e1, X[15], 8); c1 = ROL(c1, 10); + e1 = JJJ(e1, a1, b1, c1, d1, X[8], 11); b1 = ROL(b1, 10); + d1 = JJJ(d1, e1, a1, b1, c1, X[1], 14); a1 = ROL(a1, 10); + c1 = JJJ(c1, d1, e1, a1, b1, X[10], 14); e1 = ROL(e1, 10); + b1 = JJJ(b1, c1, d1, e1, a1, X[3], 12); d1 = ROL(d1, 10); + a1 = JJJ(a1, b1, c1, d1, e1, X[12], 6); c1 = ROL(c1, 10); + + e1 = III(e1, a1, b1, c1, d1, X[6], 9); b1 = ROL(b1, 10); + d1 = III(d1, e1, a1, b1, c1, X[11], 13); a1 = ROL(a1, 10); + c1 = III(c1, d1, e1, a1, b1, X[3], 15); e1 = ROL(e1, 10); + b1 = III(b1, c1, d1, e1, a1, X[7], 7); d1 = ROL(d1, 10); + a1 = III(a1, b1, c1, d1, e1, X[0], 12); c1 = ROL(c1, 10); + e1 = III(e1, a1, b1, c1, d1, X[13], 8); b1 = ROL(b1, 10); + d1 = III(d1, e1, a1, b1, c1, X[5], 9); a1 = ROL(a1, 10); + c1 = III(c1, d1, e1, a1, b1, X[10], 11); e1 = ROL(e1, 10); + b1 = III(b1, c1, d1, e1, a1, X[14], 7); d1 = ROL(d1, 10); + a1 = III(a1, b1, c1, d1, e1, X[15], 7); c1 = ROL(c1, 10); + e1 = III(e1, a1, b1, c1, d1, X[8], 12); b1 = ROL(b1, 10); + d1 = III(d1, e1, a1, b1, c1, X[12], 7); a1 = ROL(a1, 10); + c1 = III(c1, d1, e1, a1, b1, X[4], 6); e1 = ROL(e1, 10); + b1 = III(b1, c1, d1, e1, a1, X[9], 15); d1 = ROL(d1, 10); + a1 = III(a1, b1, c1, d1, e1, X[1], 13); c1 = ROL(c1, 10); + e1 = III(e1, a1, b1, c1, d1, X[2], 11); b1 = ROL(b1, 10); + + d1 = HHH(d1, e1, a1, b1, c1, X[15], 9); a1 = ROL(a1, 10); + c1 = HHH(c1, d1, e1, a1, b1, X[5], 7); e1 = ROL(e1, 10); + b1 = HHH(b1, c1, d1, e1, a1, X[1], 15); d1 = ROL(d1, 10); + a1 = HHH(a1, b1, c1, d1, e1, X[3], 11); c1 = ROL(c1, 10); + e1 = HHH(e1, a1, b1, c1, d1, X[7], 8); b1 = ROL(b1, 10); + d1 = HHH(d1, e1, a1, b1, c1, X[14], 6); a1 = ROL(a1, 10); + c1 = HHH(c1, d1, e1, a1, b1, X[6], 6); e1 = ROL(e1, 10); + b1 = HHH(b1, c1, d1, e1, a1, X[9], 14); d1 = ROL(d1, 10); + a1 = HHH(a1, b1, c1, d1, e1, X[11], 12); c1 = ROL(c1, 10); + e1 = HHH(e1, a1, b1, c1, d1, X[8], 13); b1 = ROL(b1, 10); + d1 = HHH(d1, e1, a1, b1, c1, X[12], 5); a1 = ROL(a1, 10); + c1 = HHH(c1, d1, e1, a1, b1, X[2], 14); e1 = ROL(e1, 10); + b1 = HHH(b1, c1, d1, e1, a1, X[10], 13); d1 = ROL(d1, 10); + a1 = HHH(a1, b1, c1, d1, e1, X[0], 13); c1 = ROL(c1, 10); + e1 = HHH(e1, a1, b1, c1, d1, X[4], 7); b1 = ROL(b1, 10); + d1 = HHH(d1, e1, a1, b1, c1, X[13], 5); a1 = ROL(a1, 10); + + c1 = GGG(c1, d1, e1, a1, b1, X[8], 15); e1 = ROL(e1, 10); + b1 = GGG(b1, c1, d1, e1, a1, X[6], 5); d1 = ROL(d1, 10); + a1 = GGG(a1, b1, c1, d1, e1, X[4], 8); c1 = ROL(c1, 10); + e1 = GGG(e1, a1, b1, c1, d1, X[1], 11); b1 = ROL(b1, 10); + d1 = GGG(d1, e1, a1, b1, c1, X[3], 14); a1 = ROL(a1, 10); + c1 = GGG(c1, d1, e1, a1, b1, X[11], 14); e1 = ROL(e1, 10); + b1 = GGG(b1, c1, d1, e1, a1, X[15], 6); d1 = ROL(d1, 10); + a1 = GGG(a1, b1, c1, d1, e1, X[0], 14); c1 = ROL(c1, 10); + e1 = GGG(e1, a1, b1, c1, d1, X[5], 6); b1 = ROL(b1, 10); + d1 = GGG(d1, e1, a1, b1, c1, X[12], 9); a1 = ROL(a1, 10); + c1 = GGG(c1, d1, e1, a1, b1, X[2], 12); e1 = ROL(e1, 10); + b1 = GGG(b1, c1, d1, e1, a1, X[13], 9); d1 = ROL(d1, 10); + a1 = GGG(a1, b1, c1, d1, e1, X[9], 12); c1 = ROL(c1, 10); + e1 = GGG(e1, a1, b1, c1, d1, X[7], 5); b1 = ROL(b1, 10); + d1 = GGG(d1, e1, a1, b1, c1, X[10], 15); a1 = ROL(a1, 10); + c1 = GGG(c1, d1, e1, a1, b1, X[14], 8); e1 = ROL(e1, 10); + + b1 = FFF(b1, c1, d1, e1, a1, X[12], 8); d1 = ROL(d1, 10); + a1 = FFF(a1, b1, c1, d1, e1, X[15], 5); c1 = ROL(c1, 10); + e1 = FFF(e1, a1, b1, c1, d1, X[10], 12); b1 = ROL(b1, 10); + d1 = FFF(d1, e1, a1, b1, c1, X[4], 9); a1 = ROL(a1, 10); + c1 = FFF(c1, d1, e1, a1, b1, X[1], 12); e1 = ROL(e1, 10); + b1 = FFF(b1, c1, d1, e1, a1, X[5], 5); d1 = ROL(d1, 10); + a1 = FFF(a1, b1, c1, d1, e1, X[8], 14); c1 = ROL(c1, 10); + e1 = FFF(e1, a1, b1, c1, d1, X[7], 6); b1 = ROL(b1, 10); + d1 = FFF(d1, e1, a1, b1, c1, X[0], 8); a1 = ROL(a1, 10); + c1 = FFF(c1, d1, e1, a1, b1, X[6], 5); e1 = ROL(e1, 10); + b1 = FFF(b1, c1, d1, e1, a1, X[11], 12); d1 = ROL(d1, 10); + a1 = FFF(a1, b1, c1, d1, e1, X[13], 6); c1 = ROL(c1, 10); + e1 = FFF(e1, a1, b1, c1, d1, X[2], 5); b1 = ROL(b1, 10); + d1 = FFF(d1, e1, a1, b1, c1, X[14], 15); a1 = ROL(a1, 10); + c1 = FFF(c1, d1, e1, a1, b1, X[9], 13); e1 = ROL(e1, 10); + b1 = FFF(b1, c1, d1, e1, a1, X[3], 11); d1 = ROL(d1, 10); + + var t = h1 + c + d1; + h1 = h2 + d + e1; + h2 = h3 + e + a1; + h3 = h4 + a + b1; + h4 = h0 + b + c1; + h0 = t; + } + + var result = Bytes.alloc(20); + result.set(0, h0 & 0xFF); + result.set(1, (h0 >> 8) & 0xFF); + result.set(2, (h0 >> 16) & 0xFF); + result.set(3, (h0 >> 24) & 0xFF); + result.set(4, h1 & 0xFF); + result.set(5, (h1 >> 8) & 0xFF); + result.set(6, (h1 >> 16) & 0xFF); + result.set(7, (h1 >> 24) & 0xFF); + result.set(8, h2 & 0xFF); + result.set(9, (h2 >> 8) & 0xFF); + result.set(10, (h2 >> 16) & 0xFF); + result.set(11, (h2 >> 24) & 0xFF); + result.set(12, h3 & 0xFF); + result.set(13, (h3 >> 8) & 0xFF); + result.set(14, (h3 >> 16) & 0xFF); + result.set(15, (h3 >> 24) & 0xFF); + result.set(16, h4 & 0xFF); + result.set(17, (h4 >> 8) & 0xFF); + result.set(18, (h4 >> 16) & 0xFF); + result.set(19, (h4 >> 24) & 0xFF); + + return result; + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Torrent.hx b/leenkx/Sources/leenkx/network/torrent/Torrent.hx new file mode 100644 index 00000000..8507bf66 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Torrent.hx @@ -0,0 +1,981 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.peer.IPeerChannel; +import leenkx.network.torrent.peer.WireProtocol; +import leenkx.network.torrent.peer.RtcPeerPool; +import leenkx.network.torrent.storage.Storage; +import leenkx.network.torrent.storage.Storage.IStorage; + +class Torrent { + static inline var MAX_OUTSTANDING = 8; + static inline var META_BLOCK = 16384; + static inline var MAX_META_SIZE = 10000000; + static inline var KEEPALIVE_MS = 60000; + + public var meta:MetaInfo = null; + public var infoHash:Bytes; + public var infoHashHex:String; + public var infoHashV2:Bytes = null; + public var infoHashV2Hex:String = null; + public var pendingWebSeeds:Array = null; + public var magnetURI:String = null; + public var files:Array = []; + public var ready = false; + public var done = false; + public var numPeers(get, never):Int; + function get_numPeers():Int return Lambda.count(wires); + + var listeners:Map> = []; + public var onPeerExtension:String -> String -> Bytes -> Void; + + public var uploadedBytes:Float = 0; + public var stopped = false; + public var client:TorrentClient = null; + var webSeeds:Array = []; + var lastStatT:Float = 0; + var lastStatDown:Float = 0; + var lastStatUp:Float = 0; + public var announcePort:Int = 0; + public var downloadDir:String = null; + + public function getStats():{downloaded:Float, uploaded:Float, + left:Float, downRate:Float, upRate:Float} { + var total = meta != null ? meta.totalLength : 0; + var downloaded = pieces != null + ? Math.min(pieces.numHave * (meta.pieceLength:Float), total) + : 0; + var now = haxe.Timer.stamp(); + var dt = now - lastStatT; + var downRate:Float = 0; + var upRate:Float = 0; + if (lastStatT > 0 && dt > 0) { + downRate = (downloaded - lastStatDown) / dt; + upRate = (uploadedBytes - lastStatUp) / dt; + if (downRate < 0) downRate = 0; + if (upRate < 0) upRate = 0; + } + lastStatT = now; + lastStatDown = downloaded; + lastStatUp = uploadedBytes; + return { + downloaded: downloaded, + uploaded: uploadedBytes, + left: total - downloaded, + downRate: downRate, + upRate: upRate + }; + } + + var peerId:Bytes; + var trackers:Array; + var storage:IStorage; + public var trackerObjects:Array = []; + @:allow(leenkx.network.torrent.TorrentFile) + @:allow(leenkx.network.torrent.WebSeed) + var pieces:PieceManager = null; + var wires:Map = []; + var peerBitfields:Map> = []; + var outstanding:Map = []; + var pieceCursor:Map = []; + var rtcPool:RtcPeerPool = null; + var httpTrackers:Array = []; + var udpTrackers:Array = []; + var keepaliveTimer:haxe.Timer = null; + var destroyed = false; + + var metaSize:Int = 0; + var metaReceived:Int = 0; + var metaRequested:Array = []; + var metaRejects:Int = 0; + var metaWire:WireProtocol = null; + + public function new(infoHash:Bytes, peerId:Bytes, trackers:Array, ?meta:MetaInfo, ?storage:IStorage, ?downloadDir:String) { + TorrentClient.initMain(); + this.infoHash = infoHash; + this.infoHashHex = infoHash != null + ? Crypto.toHex(infoHash) : null; + this.peerId = peerId; + this.trackers = trackers; + this.storage = storage; + this.downloadDir = downloadDir; + if (meta != null) setMeta(meta); + } + + public function on(event:String, cb:Dynamic):Void { + var l = listeners.get(event); + if (l == null) listeners.set(event, l = []); + l.push(cb); + } + + function emit(event:String):Void { + var l = listeners.get(event); + if (l != null) for (cb in l) cb(); + } + + function emit1(event:String, v:Dynamic):Void { + var l = listeners.get(event); + if (l != null) for (cb in l) cb(v); + } + + function setMeta(m:MetaInfo):Void { + meta = m; + if (storage == null) { + var sz = m.storageLength > 0 ? m.storageLength + : m.totalLength; + #if (sys || kha_krom) + storage = downloadDir != null + ? new FileTreeStorage(downloadDir, m.files) + : new MemoryStorage(sz); + #else + storage = new MemoryStorage(sz); + #end + } + if (pieces == null) { + pieces = new PieceManager(m, storage); + pieces.onPieceComplete = onPieceComplete; + pieces.onComplete = onTorrentComplete; + } + if (m.fileHashes == null && m.files != null) { + m.fileHashes = [for (f in m.files) null]; + } + files = [for (f in m.files) + new TorrentFile(f, storage, this)]; + infoHashV2 = m.infoHashV2; + infoHashV2Hex = m.infoHashV2Hex; + if (infoHash == null && m.infoHash != null) { + infoHash = m.infoHash; + infoHashHex = m.infoHashHex; + } + if (infoHashHex == null) infoHashHex = m.infoHashV2Hex; + if (m.urlList == null && pendingWebSeeds != null) { + m.urlList = pendingWebSeeds; + } + magnetURI = Magnet.build(m.infoHash, m.name, trackers, + m.urlList, m.infoHashV2); + ready = true; + initWebSeeds(); + emit1("metadata", m); + emit("ready"); + if (!pieces.complete) { + for (w in wires) w.sendInterested(); + } + } + + public function markComplete():Void { + if (pieces == null) return; + for (i in 0...meta.numPieces) pieces.markHave(i); + done = true; + } + + public function start():Void { + if (destroyed || keepaliveTimer != null) return; + stopped = false; + startTrackers(); + keepaliveTimer = new haxe.Timer(KEEPALIVE_MS); + keepaliveTimer.run = function() { + for (w in wires) w.sendKeepAlive(); + workWebSeeds(); + }; + workWebSeeds(); + } + + public function stop():Void { + if (destroyed || stopped) return; + stopped = true; + if (keepaliveTimer != null) { + keepaliveTimer.stop(); + keepaliveTimer = null; + } + for (w in [for (x in wires) x]) w.close(); + wires.clear(); + outstanding.clear(); + if (rtcPool != null) { + rtcPool.close(); + rtcPool = null; + } + for (t in httpTrackers) t.stop(); + for (t in udpTrackers) t.stop(); + httpTrackers = []; + udpTrackers = []; + trackerObjects = []; + } + + function startTrackers():Void { + for (url in trackers) startTracker(url); + } + + public function addTracker(url:String):Dynamic { + if (url == null || url == "") return null; + for (t in trackers) { + if (t == url) return null; + } + trackers.push(url); + return startTracker(url); + } + + function startTracker(url:String):Dynamic { + var infoHashBin = TorrentInfo.toBinaryString(infoHash); + var peerIdBin = TorrentInfo.toBinaryString(peerId); + var obj:Dynamic = null; + if (url.indexOf("ws") == 0) { + if (rtcPool == null) { + rtcPool = new RtcPeerPool(); + rtcPool.onPeerChannel = onRtcPeer; + rtcPool.statsProvider = getStats; + rtcPool.onError = function(e) { + emit1("error", e); + }; + } + obj = rtcPool.connect(url, infoHashBin, peerIdBin); + } + #if (sys || kha_krom) + else if (url.indexOf("http") == 0) { + var t = new leenkx.network.torrent.tracker.HttpTracker( + url, infoHash, peerId, + announcePort > 0 ? announcePort : 6881); + t.statsProvider = getStats; + t.onPeers = onClassicPeers; + t.onError = function(e) { + emit1("error", e); + }; + httpTrackers.push(t); + t.start(); + obj = t; + } + #end + #if (sys || kha_krom) + else if (url.indexOf("udp") == 0) { + var t = new leenkx.network.torrent.tracker.UdpTracker( + url, infoHash, peerId, + announcePort > 0 ? announcePort : 6881); + t.statsProvider = getStats; + t.onPeers = onClassicPeers; + t.onError = function(e) { + emit1("error", e); + }; + udpTrackers.push(t); + t.start(); + obj = t; + } + #end + if (obj != null) trackerObjects.push(obj); + return obj; + } + + function onRtcPeer(peerIdHex:String, ch:IPeerChannel):Void { + addWire("rtc:" + peerIdHex, ch); + } + + #if (sys || kha_krom) + function onClassicPeers(list:Array<{host:String, port:Int}>):Void { + for (p in list) { + var key = "tcp:" + p.host + ":" + p.port; + if (wires.exists(key)) continue; + var ch = new leenkx.network.torrent.peer.TcpPeer( + p.host, p.port); + addWire(key, ch); + } + } + #end + + public function addIncomingPeer(key:String, ch:IPeerChannel):Void { + addWire(key, ch); + } + + function addWire(key:String, ch:IPeerChannel):Void { + if (destroyed || stopped || wires.exists(key)) { + ch.close(); + return; + } + var wire = new WireProtocol(ch, infoHash, peerId); + wires.set(key, wire); + outstanding.set(key, 0); + wire.metaSize = meta != null ? meta.rawInfo.length : 0; + wire.onHandshake = function(remotePeerId) { + if (ready) wire.sendBitfield(pieces.bitfield); + }; + wire.onExtendedHandshake = function(hs) { + if (meta == null && wire.extIds.exists("ut_metadata")) { + requestMetadata(wire); + } + if (ready && !pieces.complete) wire.sendInterested(); + }; + wire.onUnchoke = function() requestBlocks(key); + wire.onBitfield = function(bf) { + peerBitfields.set(key, bitfieldToArray(bf)); + if (ready && !pieces.complete) wire.sendInterested(); + requestBlocks(key); + }; + wire.onHave = function(index) { + if (meta == null || index < 0 + || index >= meta.numPieces) return; + var bf = peerBitfields.get(key); + if (bf != null && index < bf.length) bf[index] = true; + requestBlocks(key); + }; + wire.onPiece = function(index, begin, data) { + var n = outstanding.get(key); + if (n != null && n > 0) outstanding.set(key, n - 1); + if (pieces != null && index >= 0 + && index < meta.numPieces + && pieces.addBlock(index, begin, data)) { + broadcastHave(index); + } + requestBlocks(key); + }; + wire.onRequest = function(index, begin, length) { + if (wire.amChoking || pieces == null || index < 0 + || index >= meta.numPieces || begin < 0 || length <= 0 + || begin + length > meta.pieceSize(index)) return; + if (!pieces.have[index]) return; + var data = pieces.readBlock(index, begin, length); + uploadedBytes += data.length; + wire.sendPiece(index, begin, data); + }; + wire.onChoke = function() { + outstanding.set(key, 0); + if (pieces != null) pieces.clearAllRequested(key); + }; + wire.onInterested = function() wire.sendUnchoke(); + wire.onHashRequest = function(p) onHashRequest(wire, p); + wire.onHashes = onHashesMsg; + wire.onHashReject = onHashRejectMsg; + wire.onExtended = function(name, payload) { + if (name == "ut_metadata") { + onUtMetadata(wire, payload); + } else if (onPeerExtension != null) { + onPeerExtension(key, name, payload); + } + }; + wire.onClose = function() { + wires.remove(key); + peerBitfields.remove(key); + outstanding.remove(key); + if (pieces != null) pieces.clearAllRequested(key); + if (meta == null && metaSize > 0 && metaWire == wire) { + resetMetaRequests(); + metaWire = null; + for (k in wires.keys()) { + var w = wires.get(k); + if (w != wire && w.extIds.exists("ut_metadata")) { + metaWire = w; + sendMetaRequest(w, nextMetaPiece()); + break; + } + } + } + }; + wire.onError = function(e) { + emit1("error", "wire " + key + ": " + e); + }; + wire.start(["ut_metadata", "lx_channel"]); + emit1("wire", wire); + } + + static function bytesEq(a:Bytes, b:Bytes):Bool { + if (a == null || b == null || a.length != b.length) return false; + for (i in 0...a.length) if (a.get(i) != b.get(i)) return false; + return true; + } + + function bitfieldToArray(bf:Bytes):Array { + var n = meta != null ? meta.numPieces : bf.length * 8; + var out:Array = []; + for (i in 0...n) { + var byte = i >> 3; + out.push(byte < bf.length + && (bf.get(byte) & (0x80 >> (i & 7))) != 0); + } + return out; + } + + function tryRequest(wire:WireProtocol, key:String, bf:Array, i:Int):Bool { + if (pieces.have[i] || i >= bf.length || !bf[i]) return false; + if (meta.pieces == null && meta.fileHashes != null) { + var fi = meta.fileForPiece(i); + if (fi >= 0 && meta.fileHashes[fi] == null) { + requestPieceLayer(wire, fi); + return false; + } + } + var needed = pieces.neededBlocks(key, i); + if (needed.length == 0) return false; + var b = needed[0]; + wire.sendRequest(i, b.begin, b.length); + pieces.markRequested(key, i, b.begin); + return true; + } + + function requestBlocks(key:String):Void { + if (pieces == null || pieces.complete) return; + var expired = pieces.expireRequests(60); + for (pk in expired.keys()) { + var c = outstanding.get(pk); + if (c != null) { + c -= expired.get(pk); + outstanding.set(pk, c < 0 ? 0 : c); + } + } + var wire = wires.get(key); + var bf = peerBitfields.get(key); + if (wire == null || bf == null || wire.peerChoking) return; + var n = outstanding.get(key); + var cursor = pieceCursor.get(key); + if (cursor == null) cursor = 0; + var numPieces = meta.numPieces; + while (n < MAX_OUTSTANDING) { + var sent = false; + for (sel in pieces.selections) { + for (i in sel.start...sel.end + 1) { + if (tryRequest(wire, key, bf, i)) { + n++; + sent = true; + break; + } + } + if (sent) break; + } + if (!sent) { + for (j in 0...numPieces) { + var i = (cursor + j) % numPieces; + if (tryRequest(wire, key, bf, i)) { + n++; + sent = true; + pieceCursor.set(key, (i + 1) % numPieces); + break; + } + } + } + if (!sent) break; + } + outstanding.set(key, n); + workWebSeeds(); + } + + public function select(start:Int, end:Int, ?priority:Int, ?notify:Void -> Void):Void { + if (pieces == null) return; + pieces.select(start, end, priority, notify); + for (key in wires.keys()) requestBlocks(key); + workWebSeeds(); + } + + public function deselect(start:Int, end:Int, ?priority:Int):Void { + if (pieces == null) return; + pieces.deselect(start, end, priority); + } + + public function critical(start:Int, end:Int):Void { + select(start, end, 0x7fffffff); + } + + public function addPeer(peer:String):Bool { + #if (sys || kha_krom) + if (peer == null) return false; + var i = peer.lastIndexOf(":"); + if (i < 0) return false; + var host = peer.substr(0, i); + var port = Std.parseInt(peer.substr(i + 1)); + if (host == "" || port == null) return false; + var key = "tcp:" + host + ":" + port; + if (wires.exists(key)) return false; + addWire(key, + new leenkx.network.torrent.peer.TcpPeer(host, port)); + return true; + #else + return false; + #end + } + + function broadcastHave(index:Int):Void { + for (w in wires) w.sendHave(index); + } + + public function sendExtension(key:String, name:String, payload:Bytes):Void { + if (key != null) { + var w = wires.get(key); + if (w != null) w.sendExtended(name, payload); + return; + } + for (w in wires) w.sendExtended(name, payload); + } + + function onPieceComplete(index:Int):Void { + emit1("piece", index); + } + + var hashReqNext:Map = []; + var hashReqTime:Map = []; + var pendingLayers:Map, got:Int}> = []; + + function pieceLayerBase():Int { + return Std.int(Math.log(meta.pieceLength / 16384) + / Math.log(2)); + } + + function requestPieceLayer(wire:WireProtocol, fi:Int):Void { + var f = meta.files[fi]; + var root = f.piecesRoot; + if (root == null || f.length <= 0) return; + var np = Std.int(Math.ceil(f.length / meta.pieceLength)); + var sent = hashReqNext.exists(fi) ? hashReqNext.get(fi) : 0; + if (sent >= np) { + var t = hashReqTime.exists(fi) ? hashReqTime.get(fi) : 0; + if (haxe.Timer.stamp() - t < 60) return; + sent = 0; + } + var chunk = np - sent; + if (chunk > 512) chunk = 512; + var len = 2; + while (len < chunk) len <<= 1; + wire.sendHashRequest(root, pieceLayerBase(), sent, + len, 0); + hashReqNext.set(fi, sent + len); + hashReqTime.set(fi, haxe.Timer.stamp()); + } + + function onHashesMsg(payload:Bytes):Void { + if (meta == null || meta.fileHashes == null) return; + if (payload.length < 80) return; + var root = payload.sub(0, 32); + var base = WireProtocol.readU32(payload, 32); + var index = WireProtocol.readU32(payload, 36); + var length = WireProtocol.readU32(payload, 40); + var proofs = WireProtocol.readU32(payload, 44); + if (base != pieceLayerBase()) return; + var count = Std.int((payload.length - 48) / 32); + if (count <= 0) return; + for (fi in 0...meta.files.length) { + var f = meta.files[fi]; + if (f.piecesRoot == null + || !bytesEq(root, f.piecesRoot)) continue; + if (meta.fileHashes[fi] != null) return; + var np = Std.int(Math.ceil(f.length + / meta.pieceLength)); + var pl = pendingLayers.get(fi); + if (pl == null) { + pl = {arr: [for (i in 0...np) null], got: 0}; + pendingLayers.set(fi, pl); + } + var n = count < length ? count : length; + for (j in 0...n) { + var slot = index + j; + if (slot < np && pl.arr[slot] == null) { + pl.arr[slot] = payload.sub(48 + j * 32, 32); + pl.got++; + } + } + if (pl.got >= np) { + pendingLayers.remove(fi); + if (meta.verifyLayer(fi, pl.arr)) { + meta.fileHashes[fi] = pl.arr; + pieces.flushPendingV2(fi); + for (k in wires.keys()) requestBlocks(k); + } else { + trace('[Torrent] v2 layer REJECTED fi=' + fi); + hashReqNext.remove(fi); + } + } + return; + } + } + + function onHashRejectMsg(payload:Bytes):Void { + if (meta == null) return; + var root = payload.sub(0, 32); + for (fi in 0...meta.files.length) { + var f = meta.files[fi]; + if (f.piecesRoot != null + && bytesEq(root, f.piecesRoot)) { + hashReqNext.remove(fi); + pendingLayers.remove(fi); + return; + } + } + } + + var layerCache:Map> = []; + var treeCache:Map>> = []; + + function hashLayerForFile(fi:Int):Array { + var l = layerCache.get(fi); + if (l != null) return l; + var f = meta.files[fi]; + var np = Std.int(Math.ceil(f.length / meta.pieceLength)); + l = []; + for (p in 0...np) { + var size = Std.int(Math.min(meta.pieceLength, + f.length - p * meta.pieceLength)); + l.push(meta.pieceRootV2(storage.read( + f.offset + p * meta.pieceLength, size))); + } + layerCache.set(fi, l); + return l; + } + + function treeLevelsFor(fi:Int):Array> { + var t = treeCache.get(fi); + if (t != null) return t; + var level = hashLayerForFile(fi); + t = [level]; + var depth = 0; + while (level.length > 1) { + var zero = MetaInfo.layerZero(pieceLayerBase() + + depth + 1); + var next:Array = []; + var i = 0; + while (i < level.length) { + var b = i + 1 < level.length + ? level[i + 1] : zero; + next.push(MetaInfo.hashPair(level[i], b)); + i += 2; + } + t.push(next); + level = next; + depth++; + } + if (t.length > 1) t.pop(); + treeCache.set(fi, t); + return t; + } + + function onHashRequest(wire:WireProtocol, payload:Bytes):Void { + var root = payload.sub(0, 32); + var base = WireProtocol.readU32(payload, 32); + var index = WireProtocol.readU32(payload, 36); + var length = WireProtocol.readU32(payload, 40); + var proofs = WireProtocol.readU32(payload, 44); + var reject = function() wire.sendHashReject(root, base, + index, length, proofs); + if (meta == null || meta.pieces != null || storage == null + || length < 2 || length > 512 + || (length & (length - 1)) != 0 + || (length > 0 && index % length != 0)) { + reject(); + return; + } + var pieceBase = pieceLayerBase(); + if (base != pieceBase) { + reject(); + return; + } + for (fi in 0...meta.files.length) { + var f = meta.files[fi]; + if (f.piecesRoot == null || f.length <= 0 + || !bytesEq(root, f.piecesRoot)) continue; + var layer = hashLayerForFile(fi); + if (index >= layer.length) { + reject(); + return; + } + var n = layer.length - index; + if (n > length) n = length; + var buf = new haxe.io.BytesBuffer(); + for (i in index...index + n) { + buf.addBytes(layer[i], 0, 32); + } + var lg = 0; + while ((1 << lg) < length) lg++; + var tree = treeLevelsFor(fi); + for (p in 0...(proofs - lg + 1)) { + var lvl = lg + p; + if (lvl >= tree.length) break; + var sib = (index >> lvl) ^ 1; + var h = sib < tree[lvl].length + ? tree[lvl][sib] + : MetaInfo.layerZero(pieceBase + lvl); + buf.addBytes(h, 0, 32); + } + wire.sendHashes(root, base, index, n, + proofs, buf.getBytes()); + return; + } + reject(); + } + + function onTorrentComplete():Void { + done = true; + storage.flush(); + #if sys + if (downloadDir != null + && Std.isOfType(storage, MemoryStorage)) { + writeToDisk(downloadDir); + } + #end + for (t in trackerObjects) { + var f:Dynamic = Reflect.field(t, "announceEvent"); + if (f != null) { + try { + Reflect.callMethod(t, f, ["completed"]); + } catch(e:Dynamic) {} + } + } + emit("done"); + if (client != null && client.onTorrentDone != null) { + client.onTorrentDone(this); + } + } + + #if sys + function writeToDisk(dir:String):Void { + for (f in files) { + var p = dir + "/" + MetaInfo.sanitizePath(f.path); + try { + var i = Std.int(Math.max(p.lastIndexOf("/"), + p.lastIndexOf("\\"))); + if (i > 0) { + var d = p.substr(0, i); + if (!sys.FileSystem.exists(d)) { + sys.FileSystem.createDirectory(d); + } + } + var out = sys.io.File.write(p, true); + out.write(f.getBytes()); + out.close(); + } catch(e:Dynamic) { + emit1("error", "write " + p + ": " + Std.string(e)); + } + } + } + #end + + function initWebSeeds():Void { + if (meta == null || meta.urlList == null) return; + for (u in meta.urlList) addWebSeed(u); + } + + public function addWebSeed(url:String):Void { + if (url == null || url == "") return; + for (w in webSeeds) if (w.url == url) return; + var ws = new WebSeed(url, this); + var self = this; + ws.onError = function(e) { + self.emit1("error", "webseed " + url + ": " + e); + haxe.Timer.delay(self.workWebSeeds, 10000); + }; + ws.onDone = workWebSeeds; + webSeeds.push(ws); + ws.work(); + } + + function workWebSeeds():Void { + if (destroyed || stopped || done) return; + for (w in webSeeds) w.work(); + } + + + function requestMetadata(wire:WireProtocol):Void { + if (metaSize > 0) { + var alive = false; + for (w in wires) { + if (w == metaWire) alive = true; + } + if (!alive) { + resetMetaRequests(); + metaWire = wire; + sendMetaRequest(wire, nextMetaPiece()); + } + return; + } + var hs = wire.peerExtendedHandshake; + metaWire = wire; + var size:Dynamic = Reflect.field(hs, "metadata_size"); + if (size == null) return; + var sizeF = Std.parseFloat(Std.string(size)); + if (Math.isNaN(sizeF) || sizeF <= 0 || sizeF > MAX_META_SIZE) { + emit1("error", "invalid metadata_size " + size); + return; + } + metaSize = Std.int(sizeF); + metaRejects = 0; + var count = Std.int(Math.ceil(metaSize / META_BLOCK)); + metaChunks.clear(); + metaReceived = 0; + metaRequested = [for (i in 0...count) false]; + sendMetaRequest(wire, 0); + } + + function resetMetaRequests():Void { + for (i in 0...metaRequested.length) { + if (metaRequested[i] && !metaChunks.exists(i)) { + metaRequested[i] = false; + } + } + } + + function sendMetaRequest(wire:WireProtocol, piece:Int):Void { + if (piece < 0 || piece >= metaRequested.length + || metaRequested[piece]) return; + metaRequested[piece] = true; + var req = Bencode.encode({msg_type: 0, piece: piece}); + wire.sendExtended("ut_metadata", req); + } + + function onUtMetadata(wire:WireProtocol, payload:Bytes):Void { + var headerEnd = findDictEnd(payload); + if (headerEnd < 0) return; + var header:Dynamic; + try { + header = Bencode.decode(payload.sub(0, headerEnd)); + } catch(e:Dynamic) { + return; + } + var msgType = Std.int(metaField(header, "msg_type")); + var piece = Std.int(metaField(header, "piece")); + + if (msgType == 0) { + if (meta == null || piece < 0) { + var rej = Bencode.encode({msg_type: 2, piece: piece}); + wire.sendExtended("ut_metadata", rej); + return; + } + var startF = piece * (META_BLOCK : Float); + if (startF >= meta.rawInfo.length) return; + var start = Std.int(startF); + var len = meta.rawInfo.length - start; + if (len > META_BLOCK) len = META_BLOCK; + var head = Bencode.encode({ + msg_type: 1, piece: piece, + total_size: meta.rawInfo.length + }); + var out = new haxe.io.BytesBuffer(); + out.addBytes(head, 0, head.length); + out.addBytes(meta.rawInfo, start, len); + wire.sendExtended("ut_metadata", out.getBytes()); + } else if (msgType == 1) { + var total = metaField(header, "total_size"); + if (Math.isNaN(total) || total <= 0 || total > MAX_META_SIZE) { + return; + } + var totalI = Std.int(total); + if (metaSize == 0 || totalI != metaSize) { + metaSize = totalI; + metaRejects = 0; + metaChunks.clear(); + metaReceived = 0; + metaRequested = [ + for (i in 0...Std.int(Math.ceil(totalI / META_BLOCK))) + false + ]; + } + if (piece < 0 || piece >= metaRequested.length) return; + var expected = metaSize - piece * META_BLOCK; + if (expected > META_BLOCK) expected = META_BLOCK; + var chunk = payload.sub(headerEnd, payload.length - headerEnd); + if (chunk.length > expected) { + chunk = chunk.sub(0, expected); + } + appendMetaChunk(piece, chunk); + if (metaReceived >= metaSize) finishMetadata(wire); + else sendMetaRequest(wire, nextMetaPiece()); + } else if (msgType == 2) { + if (piece >= 0 && piece < metaRequested.length) { + metaRequested[piece] = false; + metaRejects++; + if (metaRejects > 2 * metaRequested.length) { + metaSize = 0; + metaChunks.clear(); + emit1("error", "ut_metadata rejected by peers"); + } else { + for (k in wires.keys()) { + var w = wires.get(k); + if (w != wire + && w.extIds.exists("ut_metadata")) { + sendMetaRequest(w, piece); + break; + } + } + } + } + } + } + + var metaChunks:Map = []; + + function appendMetaChunk(piece:Int, chunk:Bytes):Void { + if (!metaChunks.exists(piece)) { + metaChunks.set(piece, chunk); + metaReceived += chunk.length; + } + } + + function nextMetaPiece():Int { + for (i in 0...metaRequested.length) { + if (!metaRequested[i]) return i; + } + return -1; + } + + function finishMetadata(wire:WireProtocol):Void { + var buf = new haxe.io.BytesBuffer(); + var count = Std.int(Math.ceil(metaSize / META_BLOCK)); + for (i in 0...count) { + var c = metaChunks.get(i); + if (c != null) buf.addBytes(c, 0, c.length); + } + var raw = buf.getBytes().sub(0, metaSize); + var okMeta = false; + var v1Hash:Bytes = null; + var sha1 = haxe.crypto.Sha1.make(raw); + if (infoHash != null && bytesEq(sha1, infoHash)) { + okMeta = true; + v1Hash = infoHash; + } + if (!okMeta) { + var sha2 = haxe.crypto.Sha256.make(raw); + if (infoHashV2 != null && bytesEq(sha2, infoHashV2)) { + okMeta = true; + } else if (infoHash != null + && bytesEq(sha2.sub(0, 20), infoHash)) { + okMeta = true; + } + } + if (!okMeta) { + emit1("error", "ut_metadata hash mismatch"); + metaRejects += metaRequested.length; + metaChunks.clear(); + metaReceived = 0; + if (metaRejects > 2 * metaRequested.length) { + metaSize = 0; + return; + } + for (i in 0...metaRequested.length) metaRequested[i] = false; + sendMetaRequest(wire, nextMetaPiece()); + return; + } + setMeta(MetaInfo.fromInfoDict(raw, v1Hash, trackers)); + metaChunks.clear(); + for (key in wires.keys()) requestBlocks(key); + } + + static function metaField(d:Dynamic, name:String):Float { + var v = Reflect.field(d, name); + if (v == null) return 0; + return Std.parseFloat(Std.string(v)); + } + + static function findDictEnd(data:Bytes):Int { + if (data.length == 0 || data.get(0) != 'd'.code) return -1; + var end = MetaInfo.skipValue(data, 0); + return end <= data.length ? end : -1; + } + + public function destroy():Void { + if (destroyed) return; + destroyed = true; + if (keepaliveTimer != null) { + keepaliveTimer.stop(); + keepaliveTimer = null; + } + if (rtcPool != null) rtcPool.close(); + for (t in httpTrackers) t.stop(); + for (t in udpTrackers) t.stop(); + for (w in [for (x in wires) x]) w.close(); + wires.clear(); + if (storage != null) storage.close(); + } + + public function deleteData():Void { + if (storage != null) storage.deleteData(); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/TorrentClient.hx b/leenkx/Sources/leenkx/network/torrent/TorrentClient.hx new file mode 100644 index 00000000..20ae2be5 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/TorrentClient.hx @@ -0,0 +1,352 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.storage.Storage; +import leenkx.network.torrent.storage.Storage.IStorage; + +class TorrentClient { + public static var defaultTrackers:Array = [ + "wss://ws1.leenkx.com" + ]; + + public static var defaultDownloadDir:String = null; + public var downloadDir:String = null; + + #if sys + static var mainQueue:sys.thread.Deque Void> = null; + static var mainMutex:sys.thread.Mutex = null; + static var mainTimer:haxe.Timer = null; + #end + + public static function initMain():Void { + #if sys + if (mainQueue != null) return; + mainQueue = new sys.thread.Deque(); + mainMutex = new sys.thread.Mutex(); + #end + } + + public static function postMain(f:Void -> Void):Void { + #if sys + if (mainQueue == null) { + f(); + return; + } + mainQueue.add(f); + mainMutex.acquire(); + if (mainTimer == null) { + mainTimer = new haxe.Timer(5); + mainTimer.run = drainMain; + } + mainMutex.release(); + #else + f(); + #end + } + + #if sys + static function drainMain():Void { + while (true) { + mainMutex.acquire(); + var f = mainQueue.pop(false); + if (f == null) { + mainTimer.stop(); + mainTimer = null; + mainMutex.release(); + return; + } + mainMutex.release(); + try { + f(); + } catch(e:Dynamic) {} + } + } + #end + + public var torrents:Map = []; + public var peerId:Bytes; + + public var onError:String -> Void; + public var onTorrentAdded:Torrent -> Void; + public var onTorrentDone:Torrent -> Void; + + public var listenPort:Int = 0; + var tcpListener:Dynamic = null; + + public function new() { + initMain(); + peerId = TorrentInfo.peerId(); + } + + public function listen(port:Int):Void { + #if (sys || kha_krom) + listenPort = port; + tcpListener = new leenkx.network.torrent.peer.TcpListener(port); + tcpListener.onPeer = function(ch, hex, key) { + var t = torrents.get(hex); + if (t == null) { + for (cand in torrents) { + if (cand.infoHashHex == hex) { + t = cand; + break; + } + } + } + if (t == null) { + ch.close(); + return; + } + t.addIncomingPeer(key, ch); + }; + tcpListener.onError = function(e) { + if (onError != null) onError(e); + }; + tcpListener.start(); + #end + } + + public function add(torrentId:Dynamic, ?opts:Dynamic, ?cb:Torrent -> Void):Torrent { + if (opts != null && Reflect.isFunction(opts)) { + cb = opts; + opts = null; + } + var meta:MetaInfo = null; + var infoHash:Bytes = null; + var infoHashV2:Bytes = null; + var webSeeds:Array = null; + var trackers = defaultTrackers.copy(); + var name:String = null; + + if (Std.isOfType(torrentId, Bytes)) { + meta = MetaInfo.fromTorrentBytes(torrentId); + infoHash = meta.infoHash; + infoHashV2 = meta.infoHashV2; + mergeTrackers(trackers, meta.announce); + } else { + var id = Std.string(torrentId); + var magnet = Magnet.parse(id); + if (magnet != null) { + infoHash = magnet.infoHash; + infoHashV2 = magnet.infoHashV2; + name = magnet.name; + webSeeds = magnet.webSeeds; + mergeTrackers(trackers, magnet.trackers); + } else if (id.length == 40 || id.length == 64) { + var bytes:Bytes = null; + try { + bytes = Bytes.ofHex(id); + } catch(e:Dynamic) {} + if (bytes == null) { + if (onError != null) { + onError("unrecognized torrent id: " + id); + } + return null; + } + if (bytes.length == 20) infoHash = bytes; + else infoHashV2 = bytes; + } else { + try { + meta = MetaInfo.fromTorrentBytes(Bytes.ofString(id)); + infoHash = meta.infoHash; + infoHashV2 = meta.infoHashV2; + mergeTrackers(trackers, meta.announce); + } catch(e:Dynamic) { + if (onError != null) { + onError("unrecognized torrent id: " + id); + } + return null; + } + } + } + + if (opts != null) { + var ann:Array = Reflect.field(opts, "announce"); + if (Reflect.field(opts, "announceOverride") == true) { + trackers = ann != null ? ann : []; + } else { + mergeTrackers(trackers, ann); + } + } + + var key = infoHash != null ? Crypto.toHex(infoHash) + : (infoHashV2 != null ? "v2:" + Crypto.toHex(infoHashV2) + : null); + if (key == null) { + if (onError != null) onError("unrecognized torrent id"); + return null; + } + var existing = torrents.get(key); + if (existing != null) { + if (cb != null) cb(existing); + return existing; + } + + var wireHash = infoHash != null ? infoHash + : infoHashV2.sub(0, 20); + var t = new Torrent(wireHash, peerId, trackers, meta, null, + downloadDir != null ? downloadDir : defaultDownloadDir); + t.announcePort = listenPort > 0 ? listenPort + : 49152 + Std.random(16384); + t.infoHashV2 = infoHashV2; + if (infoHashV2 != null) { + t.infoHashV2Hex = Crypto.toHex(infoHashV2); + } + t.pendingWebSeeds = webSeeds; + if (name != null && meta == null) t.magnetURI = Magnet.build( + infoHash, name, trackers, webSeeds, infoHashV2); + t.on("error", function(e) { + if (onError != null) onError(e); + }); + t.client = this; + torrents.set(key, t); + t.start(); + if (onTorrentAdded != null) onTorrentAdded(t); + if (cb != null) cb(t); + return t; + } + + public function seed(data:Dynamic, ?opts:Dynamic, ?cb:Torrent -> Void):Torrent { + if (opts != null && Reflect.isFunction(opts)) { + cb = opts; + opts = null; + } + var pair = buildPair(data, opts); + return seedMeta(pair.meta, pair.files, opts, cb); + } + + public static function buildMeta(data:Dynamic, ?opts:Dynamic):MetaInfo { + return buildPair(data, opts).meta; + } + + static function buildPair(data:Dynamic, ?opts:Dynamic):{ + meta:MetaInfo, files:Array<{path:String, data:Bytes}>} { + var name = "leenkx"; + var trackers = defaultTrackers.copy(); + if (opts != null) { + var n = Reflect.field(opts, "name"); + if (n != null) name = Std.string(n); + var ann:Array = Reflect.field(opts, "announce"); + mergeTrackers(trackers, ann); + } + + var files:Array<{path:String, data:Bytes}> = []; + if (Std.isOfType(data, Bytes)) { + files.push({path: name, data: data}); + } else if (Std.isOfType(data, Array)) { + for (f in (data:Array)) { + var p = Reflect.field(f, "path"); + var d:Bytes = Reflect.field(f, "data"); + if (d == null && Reflect.field(f, "content") != null) { + d = Reflect.field(f, "content"); + } + if (p != null && d != null) { + files.push({path: Std.string(p), data: d}); + } + } + } else { + files.push({path: name, data: Bytes.ofString(Std.string(data))}); + } + + return {meta: MetaInfo.create(name, files, trackers, opts), + files: files}; + } + + public static function readFolder(path:String, ?base:String):Array<{path:String, data:Bytes}> { + #if sys + var root = path; + while (root.length > 0 && (StringTools.endsWith(root, "/") + || StringTools.endsWith(root, "\\"))) { + root = root.substr(0, root.length - 1); + } + if (base == null) { + var i = Std.int(Math.max(root.lastIndexOf("/"), + root.lastIndexOf("\\"))); + base = i >= 0 ? root.substr(i + 1) : root; + } + var files:Array<{path:String, data:Bytes}> = []; + readFolderInto(root, base, files); + return files; + #else + throw "readFolder requires a sys target"; + #end + } + + #if sys + static function readFolderInto(dir:String, prefix:String, files:Array<{path:String, data:Bytes}>):Void { + for (entry in sys.FileSystem.readDirectory(dir)) { + var p = dir + "/" + entry; + var rel = prefix + "/" + entry; + if (sys.FileSystem.isDirectory(p)) { + readFolderInto(p, rel, files); + } else { + files.push({path: rel, data: sys.io.File.getBytes(p)}); + } + } + } + #end + + public function seedMeta(meta:MetaInfo, files:Array<{path:String, data:Bytes}>, ?opts:Dynamic, ?cb:Torrent -> Void):Torrent { + var trackers = meta.announce.copy(); + if (opts != null) { + var ann:Array = Reflect.field(opts, "announce"); + mergeTrackers(trackers, ann); + } + var ms = new MemoryStorage(meta.storageLength > 0 + ? meta.storageLength : meta.totalLength); + for (i in 0...files.length) { + ms.write(meta.files[i].offset, files[i].data); + } + var storage:IStorage = ms; + + var wireHash = meta.infoHash != null ? meta.infoHash + : meta.infoHashV2.sub(0, 20); + var t = new Torrent(wireHash, peerId, trackers, meta, storage); + t.announcePort = listenPort > 0 ? listenPort + : 49152 + Std.random(16384); + t.markComplete(); + t.on("error", function(e) { + if (onError != null) onError(e); + }); + t.client = this; + torrents.set(meta.infoHashHex != null ? meta.infoHashHex + : "v2:" + meta.infoHashV2Hex, t); + t.start(); + if (onTorrentAdded != null) onTorrentAdded(t); + if (onTorrentDone != null) onTorrentDone(t); + if (cb != null) cb(t); + return t; + } + + public function remove(infoHashHex:String, ?deleteData:Bool):Void { + var t = torrents.get(infoHashHex); + if (t == null) { + t = torrents.get("v2:" + infoHashHex); + if (t != null) infoHashHex = "v2:" + infoHashHex; + } + if (t != null) { + t.destroy(); + if (deleteData) t.deleteData(); + torrents.remove(infoHashHex); + } + } + + static function mergeTrackers(trackers:Array, urls:Array):Void { + if (urls == null) return; + for (u in urls) { + if (u != null && Lambda.indexOf(trackers, u) < 0) { + trackers.push(u); + } + } + } + + public function destroy():Void { + for (t in torrents) t.destroy(); + torrents.clear(); + #if (sys || kha_krom) + if (tcpListener != null) { + tcpListener.stop(); + tcpListener = null; + } + #end + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/TorrentFile.hx b/leenkx/Sources/leenkx/network/torrent/TorrentFile.hx new file mode 100644 index 00000000..009aef82 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/TorrentFile.hx @@ -0,0 +1,102 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; +import leenkx.network.torrent.MetaInfo.TorrentFileEntry; +import leenkx.network.torrent.storage.Storage.IStorage; + +class TorrentFile { + public var name:String; + public var path:String; + public var length:Int; + public var offset:Int; + + var storage:IStorage; + public var torrent:Torrent; + + public function new(entry:TorrentFileEntry, storage:IStorage, ?torrent:Torrent) { + this.path = entry.path; + this.name = entry.path.split("/").pop(); + this.length = entry.length; + this.offset = entry.offset; + this.storage = storage; + this.torrent = torrent; + } + + public var downloaded(get, never):Float; + function get_downloaded():Float { + if (torrent == null || torrent.pieces == null) return 0; + var pl = torrent.meta.pieceLength; + return Math.min(havePieces() * (pl:Float), length); + } + + public var progress(get, never):Float; + function get_progress():Float { + return length > 0 ? downloaded / length : 0; + } + + function havePieces():Int { + var pl = torrent.meta.pieceLength; + var first = Std.int(offset / pl); + var last = length > 0 + ? Std.int((offset + length - 1) / pl) : first; + var n = 0; + for (i in first...last + 1) { + if (torrent.pieces.have[i]) n++; + } + return n; + } + + public function select(?priority:Int):Void { + if (torrent == null) return; + torrent.select(offset, offset + length - 1, priority); + } + + public function deselect(?priority:Int):Void { + if (torrent == null) return; + torrent.deselect(offset, offset + length - 1, priority); + } + + public function critical():Void { + if (torrent == null) return; + torrent.critical(offset, offset + length - 1); + } + + public var type(get, never):String; + function get_type():String { + var i = name != null ? name.lastIndexOf(".") : -1; + var ext = i >= 0 ? name.substr(i + 1).toLowerCase() : ""; + return switch (ext) { + case "mp4" | "m4v": "video/mp4"; + case "webm": "video/webm"; + case "mkv": "video/x-matroska"; + case "mov": "video/quicktime"; + case "avi": "video/x-msvideo"; + case "mp3": "audio/mpeg"; + case "ogg": "audio/ogg"; + case "ogv": "video/ogg"; + case "flac": "audio/flac"; + case "wav": "audio/wav"; + case "aac": "audio/aac"; + case "jpg" | "jpeg": "image/jpeg"; + case "png": "image/png"; + case "gif": "image/gif"; + case "webp": "image/webp"; + case "svg": "image/svg+xml"; + case "txt" | "log" | "md": "text/plain"; + case "html" | "htm": "text/html"; + case "css": "text/css"; + case "json": "application/json"; + case "pdf": "application/pdf"; + case "zip": "application/zip"; + default: "application/octet-stream"; + } + } + + public function getBytes():Bytes { + return storage.read(offset, length); + } + + public function toString():String { + return getBytes().toString(); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/TorrentInfo.hx b/leenkx/Sources/leenkx/network/torrent/TorrentInfo.hx new file mode 100644 index 00000000..5fb45baa --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/TorrentInfo.hx @@ -0,0 +1,72 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class TorrentInfo { + + public static inline var PEERID_PREFIX = "-LX0001-"; + public static inline var PEERID_LENGTH = 20; + public static inline var HASH_LENGTH = 20; + public static inline var MIN_PIECE_LENGTH = 16384; + + public static function pieceLength(size:Int):Int { + var scaled = size < 1024 ? 1.0 : size / 1024; + var exp = Std.int(Math.log(scaled) / Math.log(2) + 0.5); + if (exp < 0) exp = 0; + if (exp > 30) exp = 30; + var len = 1 << exp; + return len > MIN_PIECE_LENGTH ? len : MIN_PIECE_LENGTH; + } + + public static function pieces(content:Bytes, plen:Int):Bytes { + var count = content.length == 0 ? 0 : Math.ceil(content.length / plen); + var out = Bytes.alloc(count * HASH_LENGTH); + for (i in 0...count) { + var off = i * plen; + var len = content.length - off; + if (len > plen) len = plen; + var sha = haxe.crypto.Sha1.make(content.sub(off, len)); + out.blit(i * HASH_LENGTH, sha, 0, HASH_LENGTH); + } + return out; + } + + public static function infoDict(name:String, content:Bytes):Dynamic { + var plen = pieceLength(content.length); + var info:Dynamic = {}; + Reflect.setField(info, "length", content.length); + Reflect.setField(info, "name", name); + Reflect.setField(info, "piece length", plen); + Reflect.setField(info, "pieces", pieces(content, plen)); + return info; + } + + public static function infoHash(name:String, content:Bytes):Bytes { + return haxe.crypto.Sha1.make(Bencode.encode(infoDict(name, content))); + } + + public static function identifierHash(identifier:String):Bytes { + return infoHash(identifier, Bytes.ofString(identifier)); + } + + public static function peerId():Bytes { + var prefix = Bytes.ofString(PEERID_PREFIX); + var random = Bytes.ofString(Crypto.toHex(Crypto.randomBytes(6))); + var out = Bytes.alloc(PEERID_LENGTH); + out.blit(0, prefix, 0, prefix.length); + out.blit(prefix.length, random, 0, PEERID_LENGTH - prefix.length); + return out; + } + + public static function toBinaryString(bytes:Bytes):String { + var sb = new StringBuf(); + for (i in 0...bytes.length) sb.addChar(bytes.get(i)); + return sb.toString(); + } + + public static function fromBinaryString(str:String):Bytes { + var bytes = Bytes.alloc(str.length); + for (i in 0...str.length) bytes.set(i, str.charCodeAt(i) & 0xFF); + return bytes; + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/TweetNaCl.hx b/leenkx/Sources/leenkx/network/torrent/TweetNaCl.hx new file mode 100644 index 00000000..8158f7df --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/TweetNaCl.hx @@ -0,0 +1,733 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class TweetNaCl { + + static var _0:Array = [for (i in 0...16) 0]; + static var _9:Array = { var a = [for (i in 0...32) 0]; a[0] = 9; a; }; + static var sigma:Array = [101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107]; + static var minusp:Array = [5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252]; + static var L:Array = [0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10]; + + static inline function gf(?init:Array):Array { + var r = [for (i in 0...16) 0.0]; + if (init != null) for (i in 0...init.length) r[i] = init[i]; + return r; + } + + static var gf0 = gf(); + static var gf1 = gf([1]); + static var _121665 = gf([0xdb41, 1]); + static var D = gf([0x78a3, 0x1359, 0x4dca, 0x75eb, 0xd8ab, 0x4141, 0x0a4d, 0x0070, 0xe898, 0x7779, 0x4079, 0x8cc7, 0xfe73, 0x2b6f, 0x6cee, 0x5203]); + static var D2 = gf([0xf159, 0x26b2, 0x9b94, 0xebd6, 0xb156, 0x8283, 0x149a, 0x00e0, 0xd130, 0xeef3, 0x80f2, 0x198e, 0xfce7, 0x56df, 0xd9dc, 0x2406]); + static var X = gf([0xd51a, 0x8f25, 0x2d60, 0xc956, 0xa7b2, 0x9525, 0xc760, 0x692c, 0xdc5c, 0xfdd6, 0xe231, 0xc0a4, 0x53fe, 0xcd6e, 0x36d3, 0x2169]); + static var Y = gf([0x6658, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666]); + static var I = gf([0xa0b0, 0x4a0e, 0x1b27, 0xc4ee, 0xe478, 0xad2f, 0x1806, 0x2f43, 0xd7a7, 0x3dfb, 0x0099, 0x2b4d, 0xdf0b, 0x4fc1, 0x2480, 0x2b83]); + + static var K:Array> = [ + [0x428a2f98, 0xd728ae22], [0x71374491, 0x23ef65cd], [0xb5c0fbcf, 0xec4d3b2f], [0xe9b5dba5, 0x8189dbbc], + [0x3956c25b, 0xf348b538], [0x59f111f1, 0xb605d019], [0x923f82a4, 0xaf194f9b], [0xab1c5ed5, 0xda6d8118], + [0xd807aa98, 0xa3030242], [0x12835b01, 0x45706fbe], [0x243185be, 0x4ee4b28c], [0x550c7dc3, 0xd5ffb4e2], + [0x72be5d74, 0xf27b896f], [0x80deb1fe, 0x3b1696b1], [0x9bdc06a7, 0x25c71235], [0xc19bf174, 0xcf692694], + [0xe49b69c1, 0x9ef14ad2], [0xefbe4786, 0x384f25e3], [0x0fc19dc6, 0x8b8cd5b5], [0x240ca1cc, 0x77ac9c65], + [0x2de92c6f, 0x592b0275], [0x4a7484aa, 0x6ea6e483], [0x5cb0a9dc, 0xbd41fbd4], [0x76f988da, 0x831153b5], + [0x983e5152, 0xee66dfab], [0xa831c66d, 0x2db43210], [0xb00327c8, 0x98fb213f], [0xbf597fc7, 0xbeef0ee4], + [0xc6e00bf3, 0x3da88fc2], [0xd5a79147, 0x930aa725], [0x06ca6351, 0xe003826f], [0x14292967, 0x0a0e6e70], + [0x27b70a85, 0x46d22ffc], [0x2e1b2138, 0x5c26c926], [0x4d2c6dfc, 0x5ac42aed], [0x53380d13, 0x9d95b3df], + [0x650a7354, 0x8baf63de], [0x766a0abb, 0x3c77b2a8], [0x81c2c92e, 0x47edaee6], [0x92722c85, 0x1482353b], + [0xa2bfe8a1, 0x4cf10364], [0xa81a664b, 0xbc423001], [0xc24b8b70, 0xd0f89791], [0xc76c51a3, 0x0654be30], + [0xd192e819, 0xd6ef5218], [0xd6990624, 0x5565a910], [0xf40e3585, 0x5771202a], [0x106aa070, 0x32bbd1b8], + [0x19a4c116, 0xb8d2d0c8], [0x1e376c08, 0x5141ab53], [0x2748774c, 0xdf8eeb99], [0x34b0bcb5, 0xe19b48a8], + [0x391c0cb3, 0xc5c95a63], [0x4ed8aa4a, 0xe3418acb], [0x5b9cca4f, 0x7763e373], [0x682e6ff3, 0xd6b2b8a3], + [0x748f82ee, 0x5defb2fc], [0x78a5636f, 0x43172f60], [0x84c87814, 0xa1f0ab72], [0x8cc70208, 0x1a6439ec], + [0x90befffa, 0x23631e28], [0xa4506ceb, 0xde82bde9], [0xbef9a3f7, 0xb2c67915], [0xc67178f2, 0xe372532b], + [0xca273ece, 0xea26619c], [0xd186b8c7, 0x21c0c207], [0xeada7dd6, 0xcde0eb1e], [0xf57d4f7f, 0xee6ed178], + [0x06f067aa, 0x72176fba], [0x0a637dc5, 0xa2c898a6], [0x113f9804, 0xbef90dae], [0x1b710b35, 0x131c471b], + [0x28db77f5, 0x23047d84], [0x32caab7b, 0x40c72493], [0x3c9ebe0a, 0x15c9bebc], [0x431d67c4, 0x9c100d4c], + [0x4cc5d4be, 0xcb3e42b6], [0x597f299c, 0xfc657e2a], [0x5fcb6fab, 0x3ad6faec], [0x6c44198c, 0x4a475817] + ]; + + static var iv:Array = [ + 0x6a, 0x09, 0xe6, 0x67, 0xf3, 0xbc, 0xc9, 0x08, + 0xbb, 0x67, 0xae, 0x85, 0x84, 0xca, 0xa7, 0x3b, + 0x3c, 0x6e, 0xf3, 0x72, 0xfe, 0x94, 0xf8, 0x2b, + 0xa5, 0x4f, 0xf5, 0x3a, 0x5f, 0x1d, 0x36, 0xf1, + 0x51, 0x0e, 0x52, 0x7f, 0xad, 0xe6, 0x82, 0xd1, + 0x9b, 0x05, 0x68, 0x8c, 0x2b, 0x3e, 0x6c, 0x1f, + 0x1f, 0x83, 0xd9, 0xab, 0xfb, 0x41, 0xbd, 0x6b, + 0x5b, 0xe0, 0xcd, 0x19, 0x13, 0x7e, 0x21, 0x79 + ]; + + static inline function u64(h:Int, l:Int):Array { return [h, l]; } + static inline function u64add(a:Array, b:Array):Array { + var a0 = a[1] & 65535, a1 = a[1] >>> 16, a2 = a[0] & 65535, a3 = a[0] >>> 16; + var b0 = b[1] & 65535, b1 = b[1] >>> 16, b2 = b[0] & 65535, b3 = b[0] >>> 16; + var s0 = a0 + b0; var s1 = a1 + b1 + (s0 >>> 16); + var s2 = a2 + b2 + (s1 >>> 16); var s3 = a3 + b3 + (s2 >>> 16); + return [(s2 & 65535) | ((s3 & 65535) << 16), (s0 & 65535) | ((s1 & 65535) << 16)]; + } + static function u64addMulti(args:Array>):Array { + var a0 = 0, a1 = 0, a2 = 0, a3 = 0; + for (v in args) { + a0 += v[1] & 65535; a1 += v[1] >>> 16; + a2 += v[0] & 65535; a3 += v[0] >>> 16; + } + a1 += a0 >>> 16; a2 += a1 >>> 16; a3 += a2 >>> 16; + return [(a2 & 65535) | ((a3 & 65535) << 16), (a0 & 65535) | ((a1 & 65535) << 16)]; + } + static inline function u64xor(a:Array, b:Array):Array { + return [a[0] ^ b[0], a[1] ^ b[1]]; + } + static function u64xorMulti(args:Array>):Array { + var h = 0, l = 0; + for (v in args) { h ^= v[0]; l ^= v[1]; } + return [h, l]; + } + static function u64R(x:Array, c:Int):Array { + var h:Int, l:Int, c1 = 32 - c; + if (c < 32) { + h = (x[0] >>> c) | (x[1] << c1); + l = (x[1] >>> c) | (x[0] << c1); + } else { + h = (x[1] >>> (c - 32)) | (x[0] << (64 - c)); + l = (x[0] >>> (c - 32)) | (x[1] << (64 - c)); + } + return [h, l]; + } + static inline function u64shr(x:Array, c:Int):Array { + return [x[0] >>> c, (x[1] >>> c) | (x[0] << (32 - c))]; + } + static inline function u64Ch(x:Array, y:Array, z:Array):Array { + return [(x[0] & y[0]) ^ (~x[0] & z[0]), (x[1] & y[1]) ^ (~x[1] & z[1])]; + } + static inline function u64Maj(x:Array, y:Array, z:Array):Array { + return [(x[0] & y[0]) ^ (x[0] & z[0]) ^ (y[0] & z[0]), (x[1] & y[1]) ^ (x[1] & z[1]) ^ (y[1] & z[1])]; + } + static inline function u64Sigma0(x:Array):Array { return u64xorMulti([u64R(x, 28), u64R(x, 34), u64R(x, 39)]); } + static inline function u64Sigma1(x:Array):Array { return u64xorMulti([u64R(x, 14), u64R(x, 18), u64R(x, 41)]); } + static inline function u64sigma0(x:Array):Array { return u64xorMulti([u64R(x, 1), u64R(x, 8), u64shr(x, 7)]); } + static inline function u64sigma1(x:Array):Array { return u64xorMulti([u64R(x, 19), u64R(x, 61), u64shr(x, 6)]); } + + static function dl64(x:Array, i:Int):Array { + var h = (x[i] << 24) | (x[i + 1] << 16) | (x[i + 2] << 8) | x[i + 3]; + var l = (x[i + 4] << 24) | (x[i + 5] << 16) | (x[i + 6] << 8) | x[i + 7]; + return [h, l]; + } + + static function ts64u(x:Array, i:Int, v:Array):Void { + x[i] = (v[0] >> 24) & 0xff; + x[i + 1] = (v[0] >> 16) & 0xff; + x[i + 2] = (v[0] >> 8) & 0xff; + x[i + 3] = v[0] & 0xff; + x[i + 4] = (v[1] >> 24) & 0xff; + x[i + 5] = (v[1] >> 16) & 0xff; + x[i + 6] = (v[1] >> 8) & 0xff; + x[i + 7] = v[1] & 0xff; + } + + static function L32(x:Int, c:Int):Int { return (x << c) | (x >>> (32 - c)); } + + static function ld32(x:Array, i:Int):Int { + var u = x[i + 3] & 0xff; + u = (u << 8) | (x[i + 2] & 0xff); + u = (u << 8) | (x[i + 1] & 0xff); + return (u << 8) | (x[i + 0] & 0xff); + } + + static function st32(x:Array, j:Int, u:Int):Void { + for (i in 0...4) { x[j + i] = u & 255; u = u >>> 8; } + } + + static function vn(x:Array, xi:Int, y:Array, yi:Int, n:Int):Int { + var d = 0; + for (i in 0...n) d |= x[xi + i] ^ y[yi + i]; + return (1 & ((d - 1) >>> 8)) - 1; + } + + static function crypto_verify_16(x:Array, xi:Int, y:Array, yi:Int):Int { return vn(x, xi, y, yi, 16); } + static function crypto_verify_32(x:Array, xi:Int, y:Array, yi:Int):Int { return vn(x, xi, y, yi, 32); } + + static function core(out:Array, inp:Array, k:Array, c:Array, h:Bool):Void { + var w = [for (i in 0...16) 0], x = [for (i in 0...16) 0], y = [for (i in 0...16) 0], t = [for (i in 0...4) 0]; + for (i in 0...4) { + x[5 * i] = ld32(c, 4 * i); x[1 + i] = ld32(k, 4 * i); + x[6 + i] = ld32(inp, 4 * i); x[11 + i] = ld32(k, 16 + 4 * i); + } + for (i in 0...16) y[i] = x[i]; + for (i in 0...20) { + for (j in 0...4) { + for (m in 0...4) t[m] = x[(5 * j + 4 * m) % 16]; + t[1] ^= L32(t[0] + t[3], 7); t[2] ^= L32(t[1] + t[0], 9); + t[3] ^= L32(t[2] + t[1], 13); t[0] ^= L32(t[3] + t[2], 18); + for (m in 0...4) w[4 * j + (j + m) % 4] = t[m]; + } + for (m in 0...16) x[m] = w[m]; + } + if (h) { + for (i in 0...16) x[i] += y[i]; + for (i in 0...4) { x[5 * i] -= ld32(c, 4 * i); x[6 + i] -= ld32(inp, 4 * i); } + for (i in 0...4) { st32(out, 4 * i, x[5 * i]); st32(out, 16 + 4 * i, x[6 + i]); } + } else { + for (i in 0...16) st32(out, 4 * i, x[i] + y[i]); + } + } + + static function crypto_stream_salsa20_xor(c:Array, cpos:Int, m:Array, mpos:Int, b:Int, n:Array, k:Array):Int { + var z = [for (i in 0...16) 0], x = [for (i in 0...64) 0]; + if (b == 0) return 0; + for (i in 0...8) z[i] = n[i]; + while (b >= 64) { + crypto_core_salsa20(x, z, k, sigma); + for (i in 0...64) c[cpos + i] = (m != null ? m[mpos + i] : 0) ^ x[i]; + var u = 1; + for (i in 8...16) { u += z[i]; z[i] = u & 0xff; u >>>= 8; } + b -= 64; cpos += 64; if (m != null) mpos += 64; + } + if (b > 0) { + crypto_core_salsa20(x, z, k, sigma); + for (i in 0...b) c[cpos + i] = (m != null ? m[mpos + i] : 0) ^ x[i]; + } + return 0; + } + + static function crypto_stream(c:Array, cpos:Int, d:Int, n:Array, k:Array):Int { + var np = [for (i in 0...32) 0]; + for (i in 0...n.length) np[i] = n[i]; + var s = [for (i in 0...32) 0]; + crypto_core_hsalsa20(s, np, k, sigma); + var n2 = [for (i in 0...16) 0]; + for (i in 0...16) n2[i] = np[16 + i]; + return crypto_stream_salsa20(c, cpos, d, n2, s); + } + + static function crypto_stream_xor(c:Array, cpos:Int, m:Array, mpos:Int, d:Int, n:Array, k:Array):Int { + var np = [for (i in 0...32) 0]; + for (i in 0...n.length) np[i] = n[i]; + var s = [for (i in 0...32) 0]; + crypto_core_hsalsa20(s, np, k, sigma); + var n2 = [for (i in 0...16) 0]; + for (i in 0...16) n2[i] = np[16 + i]; + return crypto_stream_salsa20_xor(c, cpos, m, mpos, d, n2, s); + } + + static inline function crypto_core_salsa20(out:Array, inp:Array, k:Array, c:Array):Int { core(out, inp, k, c, false); return 0; } + static inline function crypto_core_hsalsa20(out:Array, inp:Array, k:Array, c:Array):Int { core(out, inp, k, c, true); return 0; } + static inline function crypto_stream_salsa20(c:Array, cpos:Int, d:Int, n:Array, k:Array):Int { return crypto_stream_salsa20_xor(c, cpos, null, 0, d, n, k); } + + static function add1305(h:Array, c:Array):Void { + var u = 0; + for (j in 0...17) { u += h[j] + c[j]; h[j] = u & 255; u >>>= 8; } + } + + static function crypto_onetimeauth(out:Array, outpos:Int, m:Array, mpos:Int, n:Int, k:Array):Int { + var s:Int, i:Int, j:Int, u:Int; + var x = [for (i in 0...17) 0], r = [for (i in 0...17) 0], h = [for (i in 0...17) 0], c = [for (i in 0...17) 0], g = [for (i in 0...17) 0]; + for (j in 0...17) r[j] = h[j] = 0; + for (j in 0...16) r[j] = k[j]; + r[3] &= 15; r[4] &= 252; r[7] &= 15; r[8] &= 252; r[11] &= 15; r[12] &= 252; r[15] &= 15; + while (n > 0) { + for (j in 0...17) c[j] = 0; + j = 0; while (j < 16 && j < n) { c[j] = m[mpos + j]; j++; } + c[j] = 1; mpos += j; n -= j; + add1305(h, c); + for (i in 0...17) { + x[i] = 0; + for (j in 0...17) x[i] += Std.int(h[j] * ((j <= i) ? r[i - j] : (320 * r[i + 17 - j]))); + } + for (i in 0...17) h[i] = x[i]; + u = 0; + for (j in 0...16) { u += h[j]; h[j] = u & 255; u >>>= 8; } + u += h[16]; h[16] = u & 3; u = 5 * (u >>> 2); + for (j in 0...16) { u += h[j]; h[j] = u & 255; u >>>= 8; } + u += h[16]; h[16] = u; + } + for (j in 0...17) g[j] = h[j]; + add1305(h, minusp); + s = -(h[16] >>> 7); + for (j in 0...17) h[j] ^= s & (g[j] ^ h[j]); + for (j in 0...16) c[j] = k[j + 16]; + c[16] = 0; add1305(h, c); + for (j in 0...16) out[outpos + j] = h[j]; + return 0; + } + + static function crypto_secretbox(c:Array, m:Array, d:Int, n:Array, k:Array):Int { + if (d < 32) return -1; + crypto_stream_xor(c, 0, m, 0, d, n, k); + crypto_onetimeauth(c, 16, c, 32, d - 32, c); + for (i in 0...16) c[i] = 0; + return 0; + } + + static function crypto_secretbox_open(m:Array, c:Array, d:Int, n:Array, k:Array):Int { + var x = [for (i in 0...32) 0]; + if (d < 32) return -1; + crypto_stream(x, 0, 32, n, k); + if (crypto_onetimeauth_verify(c, 16, c, 32, d - 32, x) != 0) return -1; + crypto_stream_xor(m, 0, c, 0, d, n, k); + for (i in 0...32) m[i] = 0; + return 0; + } + + static inline function crypto_onetimeauth_verify(h:Array, hpos:Int, m:Array, mpos:Int, n:Int, k:Array):Int { + var x = [for (i in 0...16) 0]; + crypto_onetimeauth(x, 0, m, mpos, n, k); + return crypto_verify_16(h, hpos, x, 0); + } + + static function set25519(r:Array, a:Array):Void { for (i in 0...16) r[i] = a[i]; } + + static function car25519(o:Array):Void { + var c:Float; + for (i in 0...16) { + o[i] += 65536; + c = Math.floor(o[i] / 65536); + o[(i + 1) * (i < 15 ? 1 : 0)] += c - 1 + 37 * (c - 1) * (i == 15 ? 1 : 0); + o[i] -= c * 65536; + } + } + + static function sel25519(p:Array, q:Array, b:Int):Void { + var c = ~((b - 1)); + for (i in 0...16) { + var t:Int = c & (Std.int(p[i]) ^ Std.int(q[i])); + p[i] = Std.int(p[i]) ^ t; q[i] = Std.int(q[i]) ^ t; + } + } + + static function pack25519(o:Array, n:Array):Void { + var m = gf(), t = gf(); + for (i in 0...16) t[i] = n[i]; + car25519(t); car25519(t); car25519(t); + for (j in 0...2) { + m[0] = t[0] - 0xffed; + for (i in 1...15) { + m[i] = t[i] - 0xffff - ((Std.int(m[i - 1]) >> 16) & 1); + m[i - 1] = Std.int(m[i - 1]) & 0xffff; + } + m[15] = t[15] - 0x7fff - ((Std.int(m[14]) >> 16) & 1); + var b = (Std.int(m[15]) >> 16) & 1; + m[14] = Std.int(m[14]) & 0xffff; + sel25519(t, m, 1 - b); + } + for (i in 0...16) { + o[2 * i] = Std.int(t[i]) & 0xff; + o[2 * i + 1] = Std.int(t[i]) >> 8; + } + } + + static function neq25519(a:Array, b:Array):Int { + var c = [for (i in 0...32) 0], d = [for (i in 0...32) 0]; + pack25519(c, a); pack25519(d, b); + return crypto_verify_32(c, 0, d, 0); + } + + static function par25519(a:Array):Int { + var d = [for (i in 0...32) 0]; + pack25519(d, a); + return d[0] & 1; + } + + static function unpack25519(o:Array, n:Array):Void { + for (i in 0...16) o[i] = n[2 * i] + (n[2 * i + 1] << 8); + o[15] = Std.int(o[15]) & 0x7fff; + } + + static function A(o:Array, a:Array, b:Array):Void { for (i in 0...16) o[i] = a[i] + b[i]; } + static function Z(o:Array, a:Array, b:Array):Void { for (i in 0...16) o[i] = a[i] - b[i]; } + + static function M(o:Array, a:Array, b:Array):Void { + var t = [for (i in 0...31) 0.0]; + for (i in 0...16) for (j in 0...16) t[i + j] += a[i] * b[j]; + for (i in 0...15) t[i] += 38 * t[i + 16]; + for (i in 0...16) o[i] = t[i]; + car25519(o); car25519(o); + } + + static inline function S(o:Array, a:Array):Void { M(o, a, a); } + + static function inv25519(o:Array, i:Array):Void { + var c = gf(); + for (a in 0...16) c[a] = i[a]; + var a = 253; while (a >= 0) { + S(c, c); + if (a != 2 && a != 4) M(c, c, i); + a--; + } + for (a in 0...16) o[a] = c[a]; + } + + static function pow2523(o:Array, i:Array):Void { + var c = gf(); + for (a in 0...16) c[a] = i[a]; + var a = 250; while (a >= 0) { + S(c, c); + if (a != 1) M(c, c, i); + a--; + } + for (a in 0...16) o[a] = c[a]; + } + + static function crypto_scalarmult(q:Array, n:Array, p:Array):Int { + var z = [for (i in 0...32) 0]; + var x = [for (i in 0...80) 0.0]; + var a = gf(), b = gf(), c = gf(), d = gf(), e = gf(), f = gf(); + for (i in 0...31) z[i] = n[i]; + z[31] = (n[31] & 127) | 64; + z[0] &= 248; + unpack25519(x, p); + for (i in 0...16) { b[i] = x[i]; d[i] = a[i] = c[i] = 0; } + a[0] = d[0] = 1; + var r:Int; + var i = 254; while (i >= 0) { + r = (z[i >>> 3] >>> (i & 7)) & 1; + sel25519(a, b, r); sel25519(c, d, r); + A(e, a, c); Z(a, a, c); A(c, b, d); Z(b, b, d); + S(d, e); S(f, a); M(a, c, a); M(c, b, e); + A(e, a, c); Z(a, a, c); S(b, a); Z(c, d, f); + M(a, c, _121665); A(a, a, d); M(c, c, a); M(a, d, f); M(d, b, x); + S(b, e); sel25519(a, b, r); sel25519(c, d, r); + i--; + } + for (i in 0...16) { + x[i + 16] = a[i]; x[i + 32] = c[i]; x[i + 48] = b[i]; x[i + 64] = d[i]; + } + var x32 = [for (i in 0...16) 0.0]; for (i in 0...16) x32[i] = x[i + 32]; + var x16 = [for (i in 0...16) 0.0]; for (i in 0...16) x16[i] = x[i + 16]; + inv25519(x32, x32); + M(x16, x16, x32); + pack25519(q, x16); + return 0; + } + + static function crypto_scalarmult_base(q:Array, n:Array):Int { return crypto_scalarmult(q, n, _9); } + + static function crypto_box_beforenm(k:Array, y:Array, x:Array):Int { + var s = [for (i in 0...32) 0]; + crypto_scalarmult(s, x, y); + return crypto_core_hsalsa20(k, _0, s, sigma); + } + + static function crypto_box(c:Array, m:Array, d:Int, n:Array, y:Array, x:Array):Int { + var k = [for (i in 0...32) 0]; + crypto_box_beforenm(k, y, x); + return crypto_secretbox(c, m, d, n, k); + } + + static function crypto_box_open(m:Array, c:Array, d:Int, n:Array, y:Array, x:Array):Int { + var k = [for (i in 0...32) 0]; + crypto_box_beforenm(k, y, x); + return crypto_secretbox_open(m, c, d, n, k); + } + + static function crypto_box_keypair(y:Array, x:Array, randomFn:Int->Array):Int { + var rb = randomFn(32); + for (i in 0...32) x[i] = rb[i]; + return crypto_scalarmult_base(y, x); + } + + static function crypto_hashblocks(x:Array, m:Array, mpos:Int, n:Int):Int { + var z = [for (i in 0...8) null], b = [for (i in 0...8) null], a = [for (i in 0...8) null], w = [for (i in 0...16) null]; + for (i in 0...8) z[i] = a[i] = dl64(x, 8 * i); + while (n >= 128) { + for (i in 0...16) w[i] = dl64(m, 8 * i + mpos); + for (i in 0...80) { + for (j in 0...8) b[j] = a[j]; + var t = u64addMulti([a[7], u64Sigma1(a[4]), u64Ch(a[4], a[5], a[6]), u64(K[i][0], K[i][1]), w[i % 16]]); + b[7] = u64addMulti([t, u64Sigma0(a[0]), u64Maj(a[0], a[1], a[2])]); + b[3] = u64add(b[3], t); + for (j in 0...8) a[(j + 1) % 8] = b[j]; + if (i % 16 == 15) { + for (j in 0...16) w[j] = u64addMulti([w[j], w[(j + 9) % 16], u64sigma0(w[(j + 1) % 16]), u64sigma1(w[(j + 14) % 16])]); + } + } + for (i in 0...8) { a[i] = u64add(a[i], z[i]); z[i] = a[i]; } + mpos += 128; n -= 128; + } + for (i in 0...8) ts64u(x, 8 * i, z[i]); + return n; + } + + static function crypto_hash(out:Array, m:Array, n:Int):Int { + var h = [for (i in 0...64) 0], x = [for (i in 0...256) 0]; + var b = n; + for (i in 0...64) h[i] = iv[i]; + crypto_hashblocks(h, m, 0, n); + n = n % 128; + for (i in 0...256) x[i] = 0; + for (i in 0...n) x[i] = m[b - n + i]; + x[n] = 128; + var sz = 256 - 128 * (n < 112 ? 1 : 0); + x[sz - 9] = 0; + ts64u(x, sz - 8, u64(Std.int(b / 0x20000000), b << 3)); + crypto_hashblocks(h, x, 0, sz); + for (i in 0...64) out[i] = h[i]; + return 0; + } + + static function edAdd(p:Array>, q:Array>):Void { + var a = gf(), b = gf(), c = gf(), d = gf(), e = gf(), f = gf(), g = gf(), h = gf(), t = gf(); + Z(a, p[1], p[0]); Z(t, q[1], q[0]); M(a, a, t); + A(b, p[0], p[1]); A(t, q[0], q[1]); M(b, b, t); + M(c, p[3], q[3]); M(c, c, D2); + M(d, p[2], q[2]); A(d, d, d); + Z(e, b, a); Z(f, d, c); A(g, d, c); A(h, b, a); + M(p[0], e, f); M(p[1], h, g); M(p[2], g, f); M(p[3], e, h); + } + + static function cswap(p:Array>, q:Array>, b:Int):Void { + for (i in 0...4) sel25519(p[i], q[i], b); + } + + static function edPack(r:Array, p:Array>):Void { + var tx = gf(), ty = gf(), zi = gf(); + inv25519(zi, p[2]); M(tx, p[0], zi); M(ty, p[1], zi); + pack25519(r, ty); + r[31] ^= par25519(tx) << 7; + } + + static function edScalarmult(p:Array>, q:Array>, s:Array):Void { + set25519(p[0], gf0); set25519(p[1], gf1); set25519(p[2], gf1); set25519(p[3], gf0); + var i = 255; while (i >= 0) { + var b = (s[Std.int(i / 8)] >> (i & 7)) & 1; + cswap(p, q, b); + edAdd(q, p); + edAdd(p, p); + cswap(p, q, b); + i--; + } + } + + static function edScalarbase(p:Array>, s:Array):Void { + var q = [gf(), gf(), gf(), gf()]; + set25519(q[0], X); set25519(q[1], Y); set25519(q[2], gf1); + M(q[3], X, Y); + edScalarmult(p, q, s); + } + + static function crypto_sign_keypair(pk:Array, sk:Array, seeded:Bool, randomFn:Int->Array):Int { + var d = [for (i in 0...64) 0]; + var p = [gf(), gf(), gf(), gf()]; + if (!seeded) { + var rb = randomFn(32); + for (i in 0...32) sk[i] = rb[i]; + } + crypto_hash(d, sk, 32); + d[0] &= 248; d[31] &= 127; d[31] |= 64; + edScalarbase(p, d); + edPack(pk, p); + for (i in 0...32) sk[i + 32] = pk[i]; + return 0; + } + + static function modL(r:Array, roff:Int, x:Array):Void { + var carry:Int, i:Int, j:Int, k:Int; + i = 63; while (i >= 32) { + carry = 0; + j = i - 32; k = i - 12; + while (j < k) { + x[j] += carry - 16 * x[i] * L[j - (i - 32)]; + carry = Std.int(Math.floor((x[j] + 128) / 256)); + x[j] -= carry * 256; + j++; + } + x[j] += carry; + x[i] = 0; + i--; + } + carry = 0; + for (j in 0...32) { + x[j] += carry - (Std.int(x[31]) >> 4) * L[j]; + carry = Std.int(x[j]) >> 8; + x[j] = Std.int(x[j]) & 255; + } + for (j in 0...32) x[j] -= carry * L[j]; + for (i in 0...32) { + x[i + 1] += Std.int(x[i]) >> 8; + r[roff + i] = Std.int(x[i]) & 255; + } + } + + static function reduce(r:Array):Void { + var x = [for (i in 0...64) 0.0]; + for (i in 0...64) x[i] = r[i]; + for (i in 0...64) r[i] = 0; + modL(r, 0, x); + } + + static function crypto_sign(sm:Array, m:Array, n:Int, sk:Array):Int { + var d = [for (i in 0...64) 0], h = [for (i in 0...64) 0], r = [for (i in 0...64) 0]; + var x = [for (i in 0...64) 0.0]; + var p = [gf(), gf(), gf(), gf()]; + crypto_hash(d, sk, 32); + d[0] &= 248; d[31] &= 127; d[31] |= 64; + for (i in 0...n) sm[64 + i] = m[i]; + for (i in 0...32) sm[32 + i] = d[32 + i]; + var sm32 = [for (i in 0...n + 32) 0]; + for (i in 0...n + 32) sm32[i] = sm[32 + i]; + crypto_hash(r, sm32, n + 32); + reduce(r); + edScalarbase(p, r); + edPack(sm, p); + for (i in 32...64) sm[i] = sk[i]; + var smFull = [for (i in 0...n + 64) 0]; + for (i in 0...n + 64) smFull[i] = sm[i]; + crypto_hash(h, smFull, n + 64); + reduce(h); + for (i in 0...64) x[i] = 0; + for (i in 0...32) x[i] = r[i]; + for (i in 0...32) for (j in 0...32) x[i + j] += h[i] * d[j]; + modL(sm, 32, x); + return n + 64; + } + + static function unpackneg(r:Array>, p:Array):Int { + var t = gf(), chk = gf(), num = gf(), den = gf(), den2 = gf(), den4 = gf(), den6 = gf(); + set25519(r[2], gf1); + unpack25519(r[1], p); + S(num, r[1]); M(den, num, D); Z(num, num, r[2]); A(den, r[2], den); + S(den2, den); S(den4, den2); M(den6, den4, den2); + M(t, den6, num); M(t, t, den); + pow2523(t, t); + M(t, t, num); M(t, t, den); M(t, t, den); + M(r[0], t, den); + S(chk, r[0]); M(chk, chk, den); + if (neq25519(chk, num) != 0) M(r[0], r[0], I); + S(chk, r[0]); M(chk, chk, den); + if (neq25519(chk, num) != 0) return -1; + if (par25519(r[0]) == (p[31] >> 7)) Z(r[0], gf0, r[0]); + M(r[3], r[0], r[1]); + return 0; + } + + static function crypto_sign_open(m:Array, sm:Array, n:Int, pk:Array):Int { + var t = [for (i in 0...32) 0], h = [for (i in 0...64) 0]; + var p = [gf(), gf(), gf(), gf()], q = [gf(), gf(), gf(), gf()]; + if (n < 64) return -1; + if (unpackneg(q, pk) != 0) return -1; + for (i in 0...n) m[i] = sm[i]; + for (i in 0...32) m[i + 32] = pk[i]; + crypto_hash(h, m, n); + reduce(h); + edScalarmult(p, q, h); + var sm32 = [for (i in 0...32) 0]; + for (i in 0...32) sm32[i] = sm[32 + i]; + edScalarbase(q, sm32); + edAdd(p, q); + edPack(t, p); + n -= 64; + if (crypto_verify_32(sm, 0, t, 0) != 0) { + for (i in 0...n) m[i] = 0; + return -1; + } + for (i in 0...n) m[i] = sm[i + 64]; + return n; + } + + public static inline var crypto_box_PUBLICKEYBYTES = 32; + public static inline var crypto_box_SECRETKEYBYTES = 32; + public static inline var crypto_box_NONCEBYTES = 24; + public static inline var crypto_box_ZEROBYTES = 32; + public static inline var crypto_box_BOXZEROBYTES = 16; + public static inline var crypto_sign_BYTES = 64; + public static inline var crypto_sign_PUBLICKEYBYTES = 32; + public static inline var crypto_sign_SECRETKEYBYTES = 64; + public static inline var crypto_sign_SEEDBYTES = 32; + public static inline var crypto_hash_BYTES = 64; + + public static function hash(msg:Bytes):Bytes { + var m = bytesToArray(msg); + var out = [for (i in 0...64) 0]; + crypto_hash(out, m, msg.length); + return arrayToBytes(out); + } + + public static function signDetached(msg:Bytes, secretKey:Bytes):Bytes { + var m = bytesToArray(msg); + var sk = bytesToArray(secretKey); + var sm = [for (i in 0...crypto_sign_BYTES + msg.length) 0]; + crypto_sign(sm, m, msg.length, sk); + var sig = [for (i in 0...crypto_sign_BYTES) 0]; + for (i in 0...crypto_sign_BYTES) sig[i] = sm[i]; + return arrayToBytes(sig); + } + + public static function signDetachedVerify(msg:Bytes, sig:Bytes, publicKey:Bytes):Bool { + var sm = [for (i in 0...crypto_sign_BYTES + msg.length) 0]; + var m = [for (i in 0...crypto_sign_BYTES + msg.length) 0]; + var sigArr = bytesToArray(sig); + var msgArr = bytesToArray(msg); + for (i in 0...crypto_sign_BYTES) sm[i] = sigArr[i]; + for (i in 0...msg.length) sm[i + crypto_sign_BYTES] = msgArr[i]; + var pk = bytesToArray(publicKey); + return crypto_sign_open(m, sm, sm.length, pk) >= 0; + } + + public static function signKeyPairFromSeed(seed:Bytes):{publicKey:Bytes, secretKey:Bytes} { + var pk = [for (i in 0...32) 0]; + var sk = [for (i in 0...64) 0]; + var seedArr = bytesToArray(seed); + for (i in 0...32) sk[i] = seedArr[i]; + crypto_sign_keypair(pk, sk, true, null); + return {publicKey: arrayToBytes(pk), secretKey: arrayToBytes(sk)}; + } + + public static function boxKeyPair():{publicKey:Bytes, secretKey:Bytes} { + var pk = [for (i in 0...32) 0]; + var sk = [for (i in 0...32) 0]; + crypto_box_keypair(pk, sk, randomBytesArray); + return {publicKey: arrayToBytes(pk), secretKey: arrayToBytes(sk)}; + } + + public static function box(msg:Bytes, nonce:Bytes, publicKey:Bytes, secretKey:Bytes):Bytes { + var m = [for (i in 0...crypto_box_ZEROBYTES + msg.length) 0]; + var c = [for (i in 0...m.length) 0]; + var msgArr = bytesToArray(msg); + for (i in 0...msg.length) m[i + crypto_box_ZEROBYTES] = msgArr[i]; + var n = bytesToArray(nonce); + var pk = bytesToArray(publicKey); + var sk = bytesToArray(secretKey); + crypto_box(c, m, m.length, n, pk, sk); + var result = [for (i in 0...c.length - crypto_box_BOXZEROBYTES) 0]; + for (i in 0...result.length) result[i] = c[i + crypto_box_BOXZEROBYTES]; + return arrayToBytes(result); + } + + public static function boxOpen(box:Bytes, nonce:Bytes, publicKey:Bytes, secretKey:Bytes):Bytes { + var c = [for (i in 0...crypto_box_BOXZEROBYTES + box.length) 0]; + var m = [for (i in 0...c.length) 0]; + var boxArr = bytesToArray(box); + for (i in 0...box.length) c[i + crypto_box_BOXZEROBYTES] = boxArr[i]; + if (c.length < 32) return null; + var n = bytesToArray(nonce); + var pk = bytesToArray(publicKey); + var sk = bytesToArray(secretKey); + if (crypto_box_open(m, c, c.length, n, pk, sk) != 0) return null; + var result = [for (i in 0...m.length - crypto_box_ZEROBYTES) 0]; + for (i in 0...result.length) result[i] = m[i + crypto_box_ZEROBYTES]; + return arrayToBytes(result); + } + + public static var boxNonceLength(get, null):Int; + static inline function get_boxNonceLength():Int { return crypto_box_NONCEBYTES; } + + static function bytesToArray(b:Bytes):Array { + var a = [for (i in 0...b.length) 0]; + for (i in 0...b.length) a[i] = b.get(i); + return a; + } + + static function arrayToBytes(a:Array):Bytes { + var b = Bytes.alloc(a.length); + for (i in 0...a.length) b.set(i, a[i] & 0xff); + return b; + } + + static function randomBytesArray(n:Int):Array { + var b = Crypto.randomBytes(n); + var a = [for (i in 0...n) 0]; + for (i in 0...n) a[i] = b.get(i); + return a; + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/Types.hx b/leenkx/Sources/leenkx/network/torrent/Types.hx new file mode 100644 index 00000000..91a462e8 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/Types.hx @@ -0,0 +1,56 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +typedef LeenkxOptions = { + @:optional var seed:String; + @:optional var keyPair:{publicKey:Bytes, secretKey:Bytes}; + @:optional var timeout:Int; + @:optional var announce:Array; + @:optional var iceServers:Array; + @:optional var tracker:Dynamic; + @:optional var wtOpts:Dynamic; + @:optional var torrentOpts:Dynamic; + @:optional var torrent:Dynamic; + @:optional var wt:Dynamic; + @:optional var heartbeat:Int; + @:optional var deferJoin:Bool; + @:optional var transport:String; +} + +typedef PeerInfo = { + var pk:String; + var ek:String; + var last:Float; +} + +typedef Packet = { + var t:Float; + var i:String; + var pk:String; + var ek:String; + var n:Bytes; + @:optional var y:String; + @:optional var v:String; +} + +enum abstract PacketType(String) from String to String { + var Message = "m"; + var Ping = "p"; + var Leave = "x"; +} + +enum abstract LeenkxEvent(String) from String to String { + var Torrent = "torrent"; + var Message = "message"; + var Seen = "seen"; + var Left = "left"; + var Server = "server"; + var Ping = "ping"; + var Timeout = "timeout"; + var Connections = "connections"; + var WireSeen = "wireseen"; + var WireLeft = "wireleft"; + var Tracker = "tracker"; + var Announce = "announce"; +} diff --git a/leenkx/Sources/leenkx/network/torrent/WebSeed.hx b/leenkx/Sources/leenkx/network/torrent/WebSeed.hx new file mode 100644 index 00000000..1c8562c4 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/WebSeed.hx @@ -0,0 +1,128 @@ +package leenkx.network.torrent; + +import haxe.io.Bytes; + +class WebSeed { + public var url:String; + public var onError:String -> Void; + public var onDone:Void -> Void; + + var t:Torrent; + var busy = false; + var key:String; + + public function new(url:String, t:Torrent) { + this.url = url; + this.t = t; + this.key = "ws:" + url; + } + + public function work():Void { + if (busy || !supported() || t.pieces == null + || t.pieces.complete) return; + var i = pickPiece(); + if (i < 0) return; + var range = rangeFor(i); + if (range == null) return; + busy = true; + var off = 0; + var size = t.meta.pieceSize(i); + while (off < size) { + t.pieces.markRequested(key, i, off); + off += PieceManager.BLOCK_SIZE; + } + fetch(range.url, range.start, range.end, function(body, err) { + busy = false; + if (t.pieces != null) t.pieces.clearAllRequested(key); + if (body != null && t.pieces != null) { + t.pieces.addBlock(i, 0, body); + if (onDone != null) onDone(); + } else if (onError != null) { + onError(err != null ? err : "empty response"); + } + }); + } + + function pickPiece():Int { + var pieces = t.pieces; + for (sel in pieces.selections) { + for (i in sel.start...sel.end + 1) { + if (pieces.untouched(i) && rangeFor(i) != null) { + return i; + } + } + } + for (i in 0...t.meta.numPieces) { + if (pieces.untouched(i) && rangeFor(i) != null) return i; + } + return -1; + } + + function fileForByteRange(start:Int, end:Int):MetaInfo.TorrentFileEntry { + for (f in t.meta.files) { + if (start >= f.offset && end < f.offset + f.length) { + return f; + } + } + return null; + } + + function rangeFor(i:Int):{url:String, start:Int, end:Int} { + var pl = t.meta.pieceLength; + var start = i * pl; + var end = start + t.meta.pieceSize(i) - 1; + var f = fileForByteRange(start, end); + if (f == null) return null; + var u = url; + if (StringTools.endsWith(u, "/") || t.meta.files.length > 1) { + if (!StringTools.endsWith(u, "/")) u += "/"; + u += f.path.split("\\").join("/"); + } + return {url: u, start: start - f.offset, + end: end - f.offset}; + } + + static function supported():Bool { + #if kha_krom + return false; + #elseif (sys || js) + return true; + #else + return false; + #end + } + + function fetch(url:String, start:Int, end:Int, cb:Bytes -> String -> Void):Void { + #if kha_krom + cb(null, "webseeds unsupported on krom"); + #elseif sys + var req = new haxe.Http(url); + req.setHeader("Range", "bytes=" + start + "-" + end); + req.cnxTimeout = 60; + req.onBytes = function(b) { + TorrentClient.postMain(function() cb(b, null)); + }; + req.onError = function(e) { + TorrentClient.postMain(function() cb(null, e)); + }; + sys.thread.Thread.create(function() req.request(false)); + #elseif js + var xhr = new js.html.XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.setRequestHeader("Range", "bytes=" + start + "-" + end); + xhr.timeout = 60000; + xhr.ontimeout = function() cb(null, "request timed out"); + xhr.responseType = js.html.XMLHttpRequestResponseType + .ARRAYBUFFER; + xhr.onload = function() { + var buf:js.lib.ArrayBuffer = xhr.response; + cb(buf != null ? haxe.io.Bytes.ofData(buf) : null, + buf != null ? null : "empty response"); + }; + xhr.onerror = function() cb(null, "http error"); + xhr.send(); + #else + cb(null, "webseeds unsupported on this target"); + #end + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/peer/DataChannelPeer.hx b/leenkx/Sources/leenkx/network/torrent/peer/DataChannelPeer.hx new file mode 100644 index 00000000..28bef8f6 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/DataChannelPeer.hx @@ -0,0 +1,152 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; + +#if lnx_datachannel +import datachannel.RTCDataChannel.RTCDataChannelState; +#elseif kha_krom +import leenkx.network.torrent.transport.RtcNative; +#elseif js +import js.html.rtc.DataChannelState; +#end + +class DataChannelPeer implements IPeerChannel { + public var onOpen:Void -> Void; + public var onData:Bytes -> Void; + public var onClose:Void -> Void; + + var dc:Dynamic; + var open = false; + var pending:Array = []; + + public var isOpen(get, never):Bool; + function get_isOpen():Bool return open; + + public function new(dc:Dynamic) { + this.dc = dc; + #if lnx_datachannel + dc.onOpen = function(name) { + open = true; + if (onOpen != null) onOpen(); + }; + dc.onMessage = function(msg:Bytes) { + emitData(msg); + }; + dc.onClosed = function() { + open = false; + if (onClose != null) onClose(); + }; + dc.onError = function(msg) { + open = false; + if (onClose != null) onClose(); + }; + if (dc.state == RTCDataChannelState.Open) { + open = true; + if (onOpen != null) onOpen(); + } + #elseif kha_krom + var dcId:Int = dc; + RtcNative.setOpenCallback(dcId, function() { + open = true; + if (onOpen != null) onOpen(); + }); + RtcNative.setMessageCallback(dcId, function(data:Dynamic) { + var msg:Bytes = null; + if (Std.isOfType(data, js.lib.ArrayBuffer)) { + msg = Bytes.ofData(data); + } else if (js.lib.ArrayBuffer.isView(data)) { + var view:js.lib.ArrayBufferView = data; + msg = Bytes.ofData(view.buffer.slice( + view.byteOffset, view.byteOffset + view.byteLength)); + } else { + msg = Bytes.ofString(data); + } + emitData(msg); + }); + RtcNative.setClosedCallback(dcId, function() { + open = false; + if (onClose != null) onClose(); + }); + RtcNative.setErrorCallback(dcId, function(msg:String) { + open = false; + if (onClose != null) onClose(); + }); + if (RtcNative.isOpen(dcId)) { + open = true; + if (onOpen != null) onOpen(); + } + #elseif js + dc.binaryType = "arraybuffer"; + dc.onopen = function() { + open = true; + if (onOpen != null) onOpen(); + }; + dc.onmessage = function(e:js.html.MessageEvent) { + var msg:Bytes = null; + if (Std.isOfType(e.data, js.lib.ArrayBuffer)) { + msg = Bytes.ofData(e.data); + } else if (Std.isOfType(e.data, js.html.Blob)) { + return; + } else { + msg = Bytes.ofString(e.data); + } + emitData(msg); + }; + dc.onclose = function() { + open = false; + if (onClose != null) onClose(); + }; + dc.onerror = function(e) { + open = false; + if (onClose != null) onClose(); + }; + if (dc.readyState == DataChannelState.OPEN) { + open = true; + if (onOpen != null) onOpen(); + } + #end + } + + function emitData(msg:Bytes):Void { + if (onData == null) { + pending.push(msg); + return; + } + flushPending(); + onData(msg); + } + + function flushPending():Void { + if (onData == null || pending.length == 0) return; + var q = pending; + pending = []; + for (m in q) onData(m); + } + + public function send(data:Bytes):Void { + flushPending(); + if (!open) return; + #if lnx_datachannel + try { + dc.sendBytes(data); + } catch (e:Dynamic) { + trace("Failed to sendBytes: " + Std.string(e)); + } + #elseif kha_krom + RtcNative.sendMessage(dc, data.getData().slice(0, data.length)); + #elseif js + dc.send(new js.lib.Uint8Array(data.getData(), 0, data.length)); + #end + } + + public function close():Void { + open = false; + #if lnx_datachannel + try dc.close() catch(e:Dynamic) {} + #elseif kha_krom + try RtcNative.close(dc) catch(e:Dynamic) {} + #elseif js + try dc.close() catch(e:Dynamic) {} + #end + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/peer/IPeerChannel.hx b/leenkx/Sources/leenkx/network/torrent/peer/IPeerChannel.hx new file mode 100644 index 00000000..207c6e9c --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/IPeerChannel.hx @@ -0,0 +1,12 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; + +interface IPeerChannel { + function send(data:Bytes):Void; + function close():Void; + var isOpen(get, never):Bool; + var onOpen:Void -> Void; + var onData:Bytes -> Void; + var onClose:Void -> Void; +} diff --git a/leenkx/Sources/leenkx/network/torrent/peer/RtcPeerPool.hx b/leenkx/Sources/leenkx/network/torrent/peer/RtcPeerPool.hx new file mode 100644 index 00000000..11fa0c39 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/RtcPeerPool.hx @@ -0,0 +1,526 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; +import leenkx.network.torrent.transport.RtcSignaling; + +#if lnx_datachannel +import datachannel.RTC; +import datachannel.RTCPeerConnection.RTCState; +import datachannel.RTCPeerConnection.RTCGatheringState; +#elseif kha_krom +import leenkx.network.torrent.transport.RtcNative; +#elseif js +import js.html.rtc.PeerConnection; +import js.html.rtc.DataChannelEvent; +import js.html.rtc.IceConnectionState; +import js.html.rtc.IceGatheringState; +import js.html.rtc.SessionDescription; +import js.html.rtc.SdpType; +#end + +class RtcPeerPool { + static inline var CHANNEL_LABEL = "bt-wire"; + + var channelLabel:String = CHANNEL_LABEL; + var unordered:Bool = false; + var maxRetransmits:Int = -1; + + public var onPeerChannel:String -> IPeerChannel -> Void; + public var onPeerLeft:String -> Void; + public var onTrackerUpdate:Dynamic -> Void; + public var onReady:Void -> Void; + public var onError:String -> Void; + public var statsProvider:Void -> {downloaded:Float, uploaded:Float, + left:Float} = null; + + var signalings:Array = []; + var iceServers:Array; + #if lnx_datachannel + var eventTimer:haxe.Timer = null; + #end + + var peers:Map = []; + var channels:Map = []; + var offerPeers:Map = []; + var offerChannels:Map = []; + var offerPeerIds:Map = []; + var promotedPeerIds:Map = []; + + #if kha_krom + var dcToOffer:Map = []; + var rtcInitialized = false; + #end + + public function new(?iceServers:Array, ?channelLabel:String, unordered:Bool = false, maxRetransmits:Int = -1) { + this.iceServers = iceServers != null ? iceServers + : ["stun:stun.l.google.com:19302", + "stun:global.stun.twilio.com:3478"]; + if (channelLabel != null) this.channelLabel = channelLabel; + this.unordered = unordered; + this.maxRetransmits = maxRetransmits; + } + + public function connect(trackerUrl:String, infoHashBinary:String, peerIdBinary:String):RtcSignaling { + leenkx.network.torrent.TorrentClient.initMain(); + #if lnx_datachannel + RTC.init(); + #end + #if kha_krom + if (!rtcInitialized) { + RtcNative.init(); + rtcInitialized = true; + } + #end + + var signaling = new RtcSignaling(trackerUrl, infoHashBinary, + peerIdBinary); + signalings.push(signaling); + signaling.statsProvider = statsProvider; + signaling.onConnected = function() { + if (onReady != null) onReady(); + }; + signaling.onTrackerUpdate = function(d) { + if (onTrackerUpdate != null) onTrackerUpdate(d); + }; + signaling.onCreateOffer = onCreateOffer; + signaling.onRemoteOffer = function(peerIdHex, offerIdHex, o) { + onRemoteOffer(signaling, peerIdHex, offerIdHex, o); + }; + signaling.onRemoteAnswer = onRemoteAnswer; + signaling.onOfferTimeout = function(offerId) removeOfferPeer(offerId); + signaling.onPeerLeft = function(peerId) { + removePeer(peerId); + if (onPeerLeft != null) onPeerLeft(peerId); + }; + signaling.onError = function(msg) { + if (onError != null) onError(msg); + }; + signaling.start(); + + #if lnx_datachannel + if (eventTimer == null) { + eventTimer = new haxe.Timer(10); + eventTimer.run = function() RTC.processEvents(); + } + #end + return signaling; + } + + public function getPeerCount():Int { + return Lambda.count(channels); + } + + public function close():Void { + #if lnx_datachannel + if (eventTimer != null) { + eventTimer.stop(); + eventTimer = null; + } + #end + for (s in signalings) s.close(); + signalings = []; + for (id in [for (k in offerPeers.keys()) k]) + removeOfferPeer(id); + for (id in [for (k in peers.keys()) k]) removePeer(id); + #if kha_krom + dcToOffer.clear(); + if (rtcInitialized) { + RtcNative.cleanup(); + rtcInitialized = false; + } + #end + #if lnx_datachannel + RTC.finalize(); + #end + } + + function onCreateOffer(offerIdHex:String, cb:String -> Void):Void { + #if lnx_datachannel + var pc = new datachannel.RTCPeerConnection(iceServers, "0.0.0.0"); + offerPeers.set(offerIdHex, pc); + offerPeerIds.set(offerIdHex, {v: null}); + pc.onStateChange = function(state:RTCState) { + if (state == RTC_DISCONNECTED || state == RTC_FAILED + || state == RTC_CLOSED) { + removeOfferPeer(offerIdHex); + } + }; + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + if (!offerPeers.exists(offerIdHex)) return; + cb(pc.getLocalDescription()); + }; + pc.onGatheringStateChange = function(state) { + if (state == RTC_GATHERING_COMPLETE) sendSdp(); + }; + var dc = pc.createDatachannelWithOptions(channelLabel, unordered, + maxRetransmits < 0 ? null : maxRetransmits, null); + attachOfferChannel(offerIdHex, new DataChannelPeer(dc)); + pc.onLocalDescription = function(sdp, type) { + if (type == "offer") { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendSdp; + } + }; + + #elseif kha_krom + var pcId = RtcNative.createPeerConnection(iceServers); + offerPeers.set(offerIdHex, pcId); + offerPeerIds.set(offerIdHex, {v: null}); + RtcNative.setStateChangeCallback(pcId, function(state:Int) { + if (state == RtcNative.RTC_DISCONNECTED + || state == RtcNative.RTC_FAILED + || state == RtcNative.RTC_CLOSED) { + removeOfferPeer(offerIdHex); + } + }); + RtcNative.setDataChannelCallback(pcId, function(dcId:Int) { + dcToOffer.set(dcId, offerIdHex); + attachOfferChannel(offerIdHex, new DataChannelPeer(dcId)); + }); + var dcId = RtcNative.createDataChannel(pcId, channelLabel, + unordered, maxRetransmits); + dcToOffer.set(dcId, offerIdHex); + attachOfferChannel(offerIdHex, new DataChannelPeer(dcId)); + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + if (!offerPeers.exists(offerIdHex)) return; + cb(RtcNative.getLocalDescription(pcId)); + }; + RtcNative.setGatheringStateCallback(pcId, function(state:Int) { + if (state == RtcNative.RTC_GATHERING_COMPLETE) sendSdp(); + }); + RtcNative.setLocalDescriptionCallback(pcId, + function(sdp:String, type:String) { + if (type == "offer") { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendSdp; + } + }); + + #elseif js + var rtcConfig:Dynamic = { + iceServers: iceServers.map(function(s) return {urls: s}), + sdpSemantics: 'unified-plan' + }; + var pc = new PeerConnection(rtcConfig); + offerPeers.set(offerIdHex, pc); + offerPeerIds.set(offerIdHex, {v: null}); + pc.oniceconnectionstatechange = function(_) { + var state = pc.iceConnectionState; + if (state == IceConnectionState.FAILED + || state == IceConnectionState.DISCONNECTED + || state == IceConnectionState.CLOSED) { + removeOfferPeer(offerIdHex); + } + }; + var dc = pc.createDataChannel(channelLabel, + {ordered: !unordered, + maxRetransmits: unordered ? maxRetransmits : null}); + attachOfferChannel(offerIdHex, new DataChannelPeer(dc)); + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + var localDesc = pc.localDescription; + cb(localDesc != null ? localDesc.sdp : null); + }; + pc.onicegatheringstatechange = function(_) { + if (pc.iceGatheringState == IceGatheringState.COMPLETE) sendSdp(); + }; + pc.createOffer({}).then(function(offer) { + pc.setLocalDescription(offer).then(function(_) { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendSdp; + }); + }, function(e) { + if (onError != null) onError("onCreateOffer: " + Std.string(e)); + }); + #end + } + + function onRemoteOffer(sig:RtcSignaling, peerIdHex:String, offerIdHex:String, offer:Dynamic):Void { + var sdp:String = offer.sdp != null ? offer.sdp : offer; + #if lnx_datachannel + var pc = new datachannel.RTCPeerConnection(iceServers, "0.0.0.0"); + offerPeers.set(offerIdHex, pc); + offerPeerIds.set(offerIdHex, {v: peerIdHex}); + pc.onStateChange = function(state:RTCState) { + if (state == RTC_DISCONNECTED || state == RTC_FAILED + || state == RTC_CLOSED) { + removeOfferPeer(offerIdHex); + } + }; + pc.onDataChannel = function(dc) { + attachOfferChannel(offerIdHex, new DataChannelPeer(dc)); + }; + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendAnswerSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + if (!offerPeers.exists(offerIdHex)) return; + sig.sendAnswer(peerIdHex, offerIdHex, + pc.getLocalDescription()); + }; + pc.onGatheringStateChange = function(state) { + if (state == RTC_GATHERING_COMPLETE) sendAnswerSdp(); + }; + pc.onLocalDescription = function(desc, type) { + if (type == "answer") { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendAnswerSdp; + } + }; + try { + pc.setRemoteDescription(sdp, "offer"); + } catch(e:Dynamic) { + removeOfferPeer(offerIdHex); + if (onError != null) onError("onRemoteOffer: " + Std.string(e)); + } + + #elseif kha_krom + var pcId = RtcNative.createPeerConnection(iceServers); + offerPeers.set(offerIdHex, pcId); + offerPeerIds.set(offerIdHex, {v: peerIdHex}); + RtcNative.setStateChangeCallback(pcId, function(state:Int) { + if (state == RtcNative.RTC_DISCONNECTED + || state == RtcNative.RTC_FAILED + || state == RtcNative.RTC_CLOSED) { + removeOfferPeer(offerIdHex); + } + }); + RtcNative.setDataChannelCallback(pcId, function(dcId:Int) { + dcToOffer.set(dcId, offerIdHex); + attachOfferChannel(offerIdHex, new DataChannelPeer(dcId)); + }); + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendAnswerSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + if (!offerPeers.exists(offerIdHex)) return; + sig.sendAnswer(peerIdHex, offerIdHex, + RtcNative.getLocalDescription(pcId)); + }; + RtcNative.setGatheringStateCallback(pcId, function(state:Int) { + if (state == RtcNative.RTC_GATHERING_COMPLETE) sendAnswerSdp(); + }); + RtcNative.setLocalDescriptionCallback(pcId, + function(sdp:String, type:String) { + if (type == "answer") { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendAnswerSdp; + } + }); + var srd = RtcNative.setRemoteDescription(pcId, sdp, "offer"); + if (srd < 0) { + removeOfferPeer(offerIdHex); + if (onError != null) { + onError("onRemoteOffer: setRemoteDescription failed"); + } + return; + } + + #elseif js + var rtcConfig:Dynamic = { + iceServers: iceServers.map(function(s) return {urls: s}), + sdpSemantics: 'unified-plan' + }; + var pc = new PeerConnection(rtcConfig); + offerPeers.set(offerIdHex, pc); + offerPeerIds.set(offerIdHex, {v: peerIdHex}); + pc.oniceconnectionstatechange = function(_) { + var state = pc.iceConnectionState; + if (state == IceConnectionState.FAILED + || state == IceConnectionState.DISCONNECTED + || state == IceConnectionState.CLOSED) { + removeOfferPeer(offerIdHex); + } + }; + pc.ondatachannel = function(e:DataChannelEvent) { + attachOfferChannel(offerIdHex, new DataChannelPeer(e.channel)); + }; + var iceComplete = false; + var iceTimer:haxe.Timer = null; + var sendAnswerSdp = function() { + if (iceComplete) return; + iceComplete = true; + if (iceTimer != null) iceTimer.stop(); + var localDesc = pc.localDescription; + sig.sendAnswer(peerIdHex, offerIdHex, + localDesc != null ? localDesc.sdp : null); + }; + pc.onicegatheringstatechange = function(_) { + if (pc.iceGatheringState == IceGatheringState.COMPLETE) { + sendAnswerSdp(); + } + }; + pc.setRemoteDescription( + new SessionDescription({type: SdpType.OFFER, sdp: sdp})) + .then(function(_) { + pc.createAnswer().then(function(answer) { + pc.setLocalDescription(answer).then(function(_) { + iceTimer = new haxe.Timer(5000); + iceTimer.run = sendAnswerSdp; + }); + }); + }, function(e) { + if (onError != null) onError("onRemoteOffer: " + Std.string(e)); + }); + #end + } + + function onRemoteAnswer(peerIdHex:String, offerIdHex:String, answer:Dynamic):Void { + var sdp:String = answer.sdp != null ? answer.sdp : answer; + var box = offerPeerIds.get(offerIdHex); + if (box != null) box.v = peerIdHex; + var pc = offerPeers.get(offerIdHex); + if (pc == null) return; + #if lnx_datachannel + try { + pc.setRemoteDescription(sdp, "answer"); + } catch(e:Dynamic) { + removeOfferPeer(offerIdHex); + if (onError != null) onError("onRemoteAnswer: " + Std.string(e)); + return; + } + #elseif kha_krom + var srd = RtcNative.setRemoteDescription(pc, sdp, "answer"); + if (srd < 0) { + removeOfferPeer(offerIdHex); + if (onError != null) { + onError("onRemoteAnswer: setRemoteDescription failed"); + } + return; + } + #elseif js + pc.setRemoteDescription( + new SessionDescription({type: SdpType.ANSWER, sdp: sdp})) + .then(function(_) { + promoteOfferPeer(offerIdHex); + }, function(e) { + if (onError != null) onError("onRemoteAnswer: " + Std.string(e)); + }); + return; + #end + promoteOfferPeer(offerIdHex); + } + + function attachOfferChannel(offerIdHex:String, ch:IPeerChannel):Void { + offerChannels.set(offerIdHex, ch); + var resolvedPeerId:String = null; + var fired = false; + ch.onOpen = function() { + var box = offerPeerIds.get(offerIdHex); + resolvedPeerId = box != null ? box.v : null; + if (resolvedPeerId == null) { + resolvedPeerId = promotedPeerIds.get(offerIdHex); + } + promoteOfferPeer(offerIdHex); + if (resolvedPeerId != null && !fired) { + fired = true; + firePeerChannel(resolvedPeerId, ch); + } + }; + ch.onClose = function() { + offerChannels.remove(offerIdHex); + if (resolvedPeerId != null && channels.get(resolvedPeerId) == ch) { + removePeer(resolvedPeerId); + } else { + removeOfferPeer(offerIdHex); + } + }; + if (ch.isOpen) ch.onOpen(); + } + + function firePeerChannel(peerId:String, ch:IPeerChannel):Void { + if (onPeerChannel != null) onPeerChannel(peerId, ch); + } + + function promoteOfferPeer(offerIdHex:String):Void { + if (!offerPeers.exists(offerIdHex)) return; + var box = offerPeerIds.get(offerIdHex); + if (box == null || box.v == null) return; + var peerId = box.v; + if (peers.exists(peerId)) { + removeOfferPeer(offerIdHex); + return; + } + promotedPeerIds.set(offerIdHex, peerId); + var pc = offerPeers.get(offerIdHex); + var ch = offerChannels.get(offerIdHex); + peers.set(peerId, pc); + if (ch != null) channels.set(peerId, ch); + offerPeers.remove(offerIdHex); + offerChannels.remove(offerIdHex); + offerPeerIds.remove(offerIdHex); + #if kha_krom + for (dcId in [for (k in dcToOffer.keys()) k]) { + if (dcToOffer.get(dcId) == offerIdHex) { + dcToOffer.set(dcId, peerId); + } + } + #end + } + + function removeOfferPeer(offerIdHex:String):Void { + var pc = offerPeers.get(offerIdHex); + if (pc != null) closePc(pc); + var ch = offerChannels.get(offerIdHex); + if (ch != null) ch.close(); + offerPeers.remove(offerIdHex); + offerChannels.remove(offerIdHex); + offerPeerIds.remove(offerIdHex); + promotedPeerIds.remove(offerIdHex); + #if kha_krom + for (dcId in [for (k in dcToOffer.keys()) k]) { + if (dcToOffer.get(dcId) == offerIdHex) dcToOffer.remove(dcId); + } + #end + } + + function removePeer(peerId:String):Void { + if (!peers.exists(peerId)) return; + closePc(peers.get(peerId)); + var ch = channels.get(peerId); + if (ch != null) ch.close(); + peers.remove(peerId); + channels.remove(peerId); + for (offerId in [for (k in promotedPeerIds.keys()) k]) { + if (promotedPeerIds.get(offerId) == peerId) { + promotedPeerIds.remove(offerId); + } + } + #if kha_krom + for (dcId in [for (k in dcToOffer.keys()) k]) { + if (dcToOffer.get(dcId) == peerId) dcToOffer.remove(dcId); + } + #end + } + + function closePc(pc:Dynamic):Void { + #if lnx_datachannel + try pc.close() catch(e:Dynamic) {} + #elseif kha_krom + try { + RtcNative.closePeerConnection(pc); + RtcNative.deletePeerConnection(pc); + } catch(e:Dynamic) {} + #elseif js + try pc.close() catch(e:Dynamic) {} + #end + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/peer/TcpListener.hx b/leenkx/Sources/leenkx/network/torrent/peer/TcpListener.hx new file mode 100644 index 00000000..542cbda1 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/TcpListener.hx @@ -0,0 +1,179 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; + +#if (sys || kha_krom) +class TcpListener { + public var onPeer:IPeerChannel -> String -> String -> Void; + public var onError:String -> Void; + + var port:Int; + var stopped = false; + + public function new(port:Int = 6881) { + this.port = port; + } + +#if sys + var server:sys.net.Socket = null; + + public function start():Void { + server = new sys.net.Socket(); + server.bind(new sys.net.Host("0.0.0.0"), port); + server.listen(50); + var self = this; + sys.thread.Thread.create(function() { + while (!self.stopped) { + var s:sys.net.Socket = null; + try { + s = self.server.accept(); + } catch(e:Dynamic) { + if (self.stopped) break; + if (self.onError != null) { + self.onError("accept: " + e); + } + Sys.sleep(0.05); + continue; + } + self.handleAccepted(s); + } + }); + } + + public function stop():Void { + stopped = true; + if (server != null) { + try server.close() catch(e:Dynamic) {} + server = null; + } + } + + function handleAccepted(s:sys.net.Socket):Void { + var self = this; + sys.thread.Thread.create(function() { + var head = readHandshake(s); + if (head == null) { + try s.close() catch(e:Dynamic) {} + return; + } + var ih = head.sub(28, 20); + var hex = leenkx.network.torrent.Crypto.toHex(ih); + var peer = s.peer(); + var key = "tcp:" + peer.host.toString() + ":" + peer.port; + leenkx.network.torrent.TorrentClient.postMain(function() { + if (self.stopped || self.onPeer == null) { + try s.close() catch(e:Dynamic) {} + return; + } + var ch = new TcpPeer("", 0, s, head); + self.onPeer(ch, hex, key); + }); + }); + } + + function readHandshake(s:sys.net.Socket):Bytes { + s.setTimeout(10); + var buf = new haxe.io.BytesBuffer(); + var deadline = haxe.Timer.stamp() + 10; + while (buf.length < 68) { + try { + var tmp = Bytes.alloc(68 - buf.length); + var n = s.input.readBytes(tmp, 0, tmp.length); + if (n <= 0) return null; + buf.addBytes(tmp, 0, n); + } catch(e:Dynamic) { + if (!TcpPeer.isBlocked(e)) return null; + if (haxe.Timer.stamp() > deadline) return null; + } + } + var b = buf.getBytes(); + if (b.get(0) != 19 + || b.sub(1, 19).toString() != "BitTorrent protocol") { + return null; + } + return b; + } +#elseif kha_krom + var server:leenkx.network.krom.KromSocket = null; + var pumpRef:Void -> Void = null; + var pending:Array<{sock:leenkx.network.krom.KromSocket, + buf:haxe.io.BytesBuffer, deadline:Float}> = []; + + public function start():Void { + server = new leenkx.network.krom.KromSocket(); + server.bind(new leenkx.network.krom.KromSocket.KromHost( + "0.0.0.0"), port); + server.listen(50); + server.setBlocking(false); + pumpRef = pump; + leenkx.network.krom.KromSocket.KromPump.add(pumpRef); + } + + public function stop():Void { + stopped = true; + if (pumpRef != null) { + leenkx.network.krom.KromSocket.KromPump.remove(pumpRef); + pumpRef = null; + } + for (p in pending) try p.sock.close() catch(e:Dynamic) {} + pending = []; + if (server != null) { + server.close(); + server = null; + } + } + + function pump():Void { + if (stopped || server == null) return; + while (true) { + var s:leenkx.network.krom.KromSocket = null; + try { + s = server.accept(); + } catch(e:Dynamic) { + break; + } + if (s == null) break; + s.setBlocking(false); + pending.push({ + sock: s, + buf: new haxe.io.BytesBuffer(), + deadline: haxe.Timer.stamp() + 10 + }); + } + var i = pending.length; + while (i-- > 0) { + var p = pending[i]; + try { + var d = p.sock.recvRaw(68 - p.buf.length); + if (d != null) p.buf.addBytes(d, 0, d.length); + } catch(e:Dynamic) { + p.sock.close(); + pending.splice(i, 1); + continue; + } + if (p.buf.length >= 68) { + pending.splice(i, 1); + routePeer(p.sock, p.buf.getBytes()); + } else if (haxe.Timer.stamp() > p.deadline) { + p.sock.close(); + pending.splice(i, 1); + } + } + } + + function routePeer(s:leenkx.network.krom.KromSocket, head:Bytes):Void { + if (head.get(0) != 19 + || head.sub(1, 19).toString() != "BitTorrent protocol" + || stopped || onPeer == null) { + s.close(); + return; + } + var ih = head.sub(28, 20); + var hex = leenkx.network.torrent.Crypto.toHex(ih); + var key = "krom:" + s.getSocketId(); + var ch = new TcpPeer("", 0, s, head); + onPeer(ch, hex, key); + } +#end +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/peer/TcpPeer.hx b/leenkx/Sources/leenkx/network/torrent/peer/TcpPeer.hx new file mode 100644 index 00000000..84143700 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/TcpPeer.hx @@ -0,0 +1,406 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; + +#if (sys || kha_krom) +class TcpPeer implements IPeerChannel { + public var onOpen:Void -> Void; + public var onData:Bytes -> Void; + public var onClose:Void -> Void; + + static var peers:Array = []; + #if sys + static var peersMutex = new sys.thread.Mutex(); + static var ioThread:sys.thread.Thread = null; + static var wakeRecv:sys.net.UdpSocket = null; + static var wakeAddr:sys.net.Address = null; +#elseif kha_krom + static var pumpTickRef:Void -> Void = null; + #end + + var open = false; + var dead = false; + var pendingSend:Array = []; + #if sys + var sendMutex = new sys.thread.Mutex(); + #end + var head:Bytes = null; + var readBuf:Bytes = Bytes.alloc(65536); + #if kha_krom + var connectDeadline:Float = 0; + #end + + #if kha_krom + var socket:leenkx.network.krom.KromSocket; + #else + var socket:sys.net.Socket; + #end + + public var isOpen(get, never):Bool; + function get_isOpen():Bool return open; + + public function new(host:String, port:Int, ?acceptedSocket:Dynamic, ?headData:Bytes) { + if (acceptedSocket != null) { + socket = acceptedSocket; + head = headData; + socket.setBlocking(false); + open = true; + startPump(); + var self = this; + leenkx.network.torrent.TorrentClient.postMain(function() { + if (self.onOpen != null) self.onOpen(); + }); + return; + } + #if kha_krom + socket = new leenkx.network.krom.KromSocket(); + socket.setBlocking(false); + var connectFailed = false; + try { + socket.connect( + new leenkx.network.krom.KromSocket.KromHost(host), port); + } catch(e:Dynamic) { + connectFailed = true; + } + if (connectFailed) { + dead = true; + return; + } + if (socket.isConnected()) { + open = true; + startPump(); + if (onOpen != null) onOpen(); + } else { + connectDeadline = haxe.Timer.stamp() + 10; + startPump(); + } + #else + var self = this; + sys.thread.Thread.create(function() { + var s = new sys.net.Socket(); + var err:Dynamic = null; + try { + s.connect(new sys.net.Host(host), port); + s.setBlocking(false); + } catch(e:Dynamic) { + err = e; + } + leenkx.network.torrent.TorrentClient.postMain(function() { + if (self.dead) { + try s.close() catch(e:Dynamic) {} + return; + } + if (err != null) { + self.dead = true; + if (self.onClose != null) self.onClose(); + return; + } + self.socket = s; + self.open = true; + self.startPump(); + if (self.onOpen != null) self.onOpen(); + }); + }); + #end + } + + function fail():Void { + dead = true; + open = false; + stopPump(); + if (onClose != null) onClose(); + } + + function deliverHead():Void { + if (head == null || onData == null) return; + var d = head; + head = null; + onData(d); + } + + #if sys + function startPump():Void register(this); + function stopPump():Void unregister(this); + + static function register(p:TcpPeer):Void { + peersMutex.acquire(); + if (!peers.contains(p)) peers.push(p); + if (ioThread == null) { + wakeRecv = new sys.net.UdpSocket(); + wakeRecv.bind(new sys.net.Host("127.0.0.1"), 0); + wakeRecv.setBlocking(false); + var h = wakeRecv.host(); + wakeAddr = new sys.net.Address(); + wakeAddr.host = h.host.ip; + wakeAddr.port = h.port; + ioThread = sys.thread.Thread.create(ioLoop); + } + peersMutex.release(); + ioWake(); + } + + static function unregister(p:TcpPeer):Void { + peersMutex.acquire(); + peers.remove(p); + peersMutex.release(); + ioWake(); + } + + static function ioWake():Void { + if (wakeAddr == null) return; + try { + wakeRecv.sendTo(Bytes.alloc(1), 0, 1, wakeAddr); + } catch(e:Dynamic) {} + } + + static function ioLoop():Void { + var wakeBuf = Bytes.alloc(64); + while (true) { + peersMutex.acquire(); + var snapshot = peers.copy(); + peersMutex.release(); + for (p in snapshot) { + try { + p.deliverHead(); + } catch(e:Dynamic) { + trace("[TcpPeer.ioLoop] deliverHead threw: " + e); + } + } + var reads:Array = [wakeRecv]; + var writes:Array = []; + for (p in snapshot) { + if (p.dead || p.socket == null) continue; + reads.push(p.socket); + p.sendMutex.acquire(); + var hasPending = p.pendingSend.length > 0; + p.sendMutex.release(); + if (hasPending) writes.push(p.socket); + } + var res = try { + sys.net.Socket.select(reads, writes, null, 0.1); + } catch(e:Dynamic) { + null; + } + if (res == null) continue; + if (res.read.contains(wakeRecv)) { + try { + while (wakeRecv.readFrom(wakeBuf, 0, 64, + new sys.net.Address()) > 0) {} + } catch(e:Dynamic) {} + } + for (s in res.read) { + if (s == wakeRecv) continue; + for (p in snapshot) { + if (p.socket == s && !p.dead) { + try { + p.ioRead(); + } catch(e:Dynamic) { + trace("[TcpPeer.ioLoop] ioRead threw: " + + e); + p.fail(); + } + } + } + } + for (s in res.write) { + for (p in snapshot) { + if (p.socket == s && !p.dead) { + try { + p.ioWrite(); + } catch(e:Dynamic) { + trace("[TcpPeer.ioLoop] ioWrite threw: " + + e); + p.fail(); + } + } + } + } + peersMutex.acquire(); + var i = peers.length; + while (i-- > 0) { + if (peers[i].dead) peers.splice(i, 1); + } + peersMutex.release(); + } + } + + function ioRead():Void { + try { + while (true) { + var read = socket.input.readBytes(readBuf, 0, + readBuf.length); + if (read <= 0) { + fail(); + return; + } + if (onData != null) onData(readBuf.sub(0, read)); + } + } catch(e:Dynamic) { + if (isBlocked(e)) return; + fail(); + } + } + + function ioWrite():Void { + flushPending(); + } +#elseif kha_krom + function startPump():Void { + if (pumpTickRef == null) { + pumpTickRef = pumpAll; + leenkx.network.krom.KromSocket.KromPump.add(pumpTickRef); + } + peers.push(this); + } + + function stopPump():Void { + peers.remove(this); + if (peers.length == 0 && pumpTickRef != null) { + leenkx.network.krom.KromSocket.KromPump.remove(pumpTickRef); + pumpTickRef = null; + } + } + + static function pumpAll():Void { + var i = peers.length; + while (i-- > 0) { + if (peers[i].dead) peers.splice(i, 1); + } + var snapshot = peers.copy(); + for (p in snapshot) { + if (!p.dead) p.pumpTick(); + } + if (peers.length == 0 && pumpTickRef != null) { + leenkx.network.krom.KromSocket.KromPump.remove(pumpTickRef); + pumpTickRef = null; + } + } + + function pumpTick():Void { + if (dead) return; + if (connectDeadline > 0) { + if (socket.isConnected()) { + connectDeadline = 0; + open = true; + var queued = pendingSend; + pendingSend = []; + for (d in queued) send(d); + if (onOpen != null) onOpen(); + } else if (haxe.Timer.stamp() > connectDeadline) { + connectDeadline = 0; + fail(); + } + return; + } + deliverHead(); + flushPending(); + if (dead) return; + try { + while (true) { + var data = socket.recvRaw(readBuf.length); + if (data == null || data.length == 0) break; + if (onData != null) onData(data); + } + } catch(e:Dynamic) { + if (isBlocked(e)) return; + fail(); + } + } +#end + + #if sys + public function send(data:Bytes):Void { + if (dead) return; + sendMutex.acquire(); + pendingSend.push(data); + sendMutex.release(); + ioWake(); + } + #else + public function send(data:Bytes):Void { + if (dead) return; + if (!open || pendingSend.length > 0) { + pendingSend.push(data); + return; + } + try { + var written = socket.sendRaw(data); + if (written < data.length) { + pendingSend.push(data.sub(written, data.length - written)); + } + } catch(e:Dynamic) { + if (isBlocked(e)) { + pendingSend.push(data); + return; + } + dead = true; + open = false; + stopPump(); + if (onClose != null) onClose(); + } + } + #end + + public static function isBlocked(e:Dynamic):Bool { + var msg = Std.string(e); + if (msg == "Blocking" || msg == "Blocked") return true; + if (Std.isOfType(e, haxe.io.Error) + && (e:haxe.io.Error).match(haxe.io.Error.Blocked)) return true; + if (msg.indexOf("Unix_error(37") == 0) return true; + return false; + } + + #if sys + function flushPending():Void { + while (open && !dead) { + sendMutex.acquire(); + var empty = pendingSend.length == 0; + var d = empty ? null : pendingSend[0]; + sendMutex.release(); + if (empty) return; + try { + var written = socket.output.writeBytes(d, 0, d.length); + socket.output.flush(); + sendMutex.acquire(); + if (written < d.length) { + pendingSend[0] = d.sub(written, d.length - written); + } else { + pendingSend.shift(); + } + sendMutex.release(); + if (written < d.length) return; + } catch(e:Dynamic) { + if (isBlocked(e)) return; + fail(); + return; + } + } + } + #else + function flushPending():Void { + while (open && !dead && pendingSend.length > 0) { + var d = pendingSend[0]; + try { + var written = socket.sendRaw(d); + if (written < d.length) { + pendingSend[0] = d.sub(written, d.length - written); + break; + } + pendingSend.shift(); + } catch(e:Dynamic) { + if (isBlocked(e)) break; + fail(); + break; + } + } + } + #end + + public function close():Void { + open = false; + dead = true; + stopPump(); + try socket.close() catch(e:Dynamic) {} + } +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/peer/WireProtocol.hx b/leenkx/Sources/leenkx/network/torrent/peer/WireProtocol.hx new file mode 100644 index 00000000..1db4f1ae --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/peer/WireProtocol.hx @@ -0,0 +1,424 @@ +package leenkx.network.torrent.peer; + +import haxe.io.Bytes; +import leenkx.network.torrent.Bencode; + +class WireProtocol { + static inline var PSTR = "BitTorrent protocol"; + static inline var BLOCK_SIZE = 16384; + static inline var MAX_MSG_LEN = 256 * 1024; + + public static inline var MSG_CHOKE = 0; + public static inline var MSG_UNCHOKE = 1; + public static inline var MSG_INTERESTED = 2; + public static inline var MSG_NOT_INTERESTED = 3; + public static inline var MSG_HAVE = 4; + public static inline var MSG_BITFIELD = 5; + public static inline var MSG_REQUEST = 6; + public static inline var MSG_PIECE = 7; + public static inline var MSG_CANCEL = 8; + public static inline var MSG_PORT = 9; + public static inline var MSG_EXTENDED = 20; + public static inline var MSG_HASH_REQUEST = 21; + public static inline var MSG_HASHES = 22; + public static inline var MSG_HASH_REJECT = 23; + + var channel:IPeerChannel; + var infoHash:Bytes; + var peerId:Bytes; + var buffer:haxe.io.BytesBuffer = new haxe.io.BytesBuffer(); + var handshakeDone = false; + var handshakeSent = false; + var started = false; + var closed = false; + + public var extIds:Map = []; + public var peerExtendedHandshake:Dynamic = null; + public var remotePeerId:String = null; + public var peerChoking = true; + public var peerInterested = false; + public var amChoking = true; + public var amInterested = false; + + var localExtNames:Array = []; + var localExtIds:Map = []; + + public var onHandshake:String -> Void; + public var onExtendedHandshake:Dynamic -> Void; + public var onChoke:Void -> Void; + public var onUnchoke:Void -> Void; + public var onInterested:Void -> Void; + public var onNotInterested:Void -> Void; + public var onHave:Int -> Void; + public var onBitfield:Bytes -> Void; + public var onRequest:Int -> Int -> Int -> Void; + public var onPiece:Int -> Int -> Bytes -> Void; + public var onCancel:Int -> Int -> Int -> Void; + public var onExtended:String -> Bytes -> Void; + public var onHashRequest:Bytes -> Void; + public var onHashes:Bytes -> Void; + public var onHashReject:Bytes -> Void; + public var onClose:Void -> Void; + public var onError:String -> Void; + + public function new(channel:IPeerChannel, infoHash:Bytes, peerId:Bytes) { + this.channel = channel; + this.infoHash = infoHash; + this.peerId = peerId; + channel.onData = onChannelData; + var prevClose = channel.onClose; + channel.onClose = function() { + if (prevClose != null) prevClose(); + closed = true; + if (onClose != null) onClose(); + }; + } + + public function start(extNames:Array):Void { + localExtNames = extNames; + started = true; + sendHandshake(); + pump(); + } + + function sendHandshake():Void { + if (handshakeSent) return; + handshakeSent = true; + var out = new haxe.io.BytesBuffer(); + out.addByte(PSTR.length); + out.addString(PSTR); + var reserved = Bytes.alloc(8); + reserved.set(5, 0x10); + out.addBytes(reserved, 0, 8); + out.addBytes(infoHash, 0, 20); + out.addBytes(peerId, 0, 20); + channel.send(out.getBytes()); + } + + var parsing = false; + + function onChannelData(data:Bytes):Void { + if (closed) return; + buffer.addBytes(data, 0, data.length); + if (!started) return; + pump(); + } + + function pump():Void { + if (parsing) return; + parsing = true; + while (!closed) { + var bytes = buffer.getBytes(); + buffer = new haxe.io.BytesBuffer(); + if (bytes.length == 0) break; + var consumed = parse(bytes); + if (consumed < bytes.length) { + var nested = buffer.getBytes(); + buffer = new haxe.io.BytesBuffer(); + buffer.addBytes(bytes, consumed, bytes.length - consumed); + buffer.addBytes(nested, 0, nested.length); + if (consumed == 0 && nested.length == 0) break; + } + } + parsing = false; + } + + function parse(data:Bytes):Int { + var pos = 0; + if (!handshakeDone) { + if (data.length < 68) return 0; + var pstrLen = data.get(0); + if (pstrLen != PSTR.length) { + fail("bad pstr length"); + return data.length; + } + var pstr = data.sub(1, pstrLen).toString(); + if (pstr != PSTR) { + fail("bad pstr"); + return data.length; + } + var ihOff = 1 + pstrLen + 8; + var remoteHash = data.sub(ihOff, 20); + if (!bytesEqual(remoteHash, infoHash)) { + fail("info_hash mismatch"); + return data.length; + } + remotePeerId = leenkx.network.torrent.Crypto.toHex( + data.sub(ihOff + 20, 20)); + handshakeDone = true; + pos = ihOff + 40; + sendHandshake(); + sendExtendedHandshake(); + if (onHandshake != null) onHandshake(remotePeerId); + } + while (pos + 4 <= data.length) { + var len = (data.get(pos) << 24) | (data.get(pos + 1) << 16) + | (data.get(pos + 2) << 8) | data.get(pos + 3); + if (len < 0 || len > MAX_MSG_LEN || pos + 4 + len > data.length) break; + var msgStart = pos; + pos += 4; + if (len > 0) { + var mid = data.get(pos); + handleMessage(mid, data.sub(pos + 1, len - 1)); + } + pos += len; + } + return pos; + } + + function handleMessage(id:Int, payload:Bytes):Void { + switch (id) { + case MSG_CHOKE: + peerChoking = true; + if (onChoke != null) onChoke(); + case MSG_UNCHOKE: + peerChoking = false; + if (onUnchoke != null) onUnchoke(); + case MSG_INTERESTED: + peerInterested = true; + if (onInterested != null) onInterested(); + case MSG_NOT_INTERESTED: + peerInterested = false; + if (onNotInterested != null) onNotInterested(); + case MSG_HAVE: + if (payload.length >= 4 && onHave != null) { + onHave(readInt32(payload, 0)); + } + case MSG_BITFIELD: + if (onBitfield != null) onBitfield(payload); + case MSG_REQUEST: + if (payload.length >= 12 && onRequest != null) { + onRequest(readInt32(payload, 0), readInt32(payload, 4), + readInt32(payload, 8)); + } + case MSG_PIECE: + if (payload.length >= 8 && onPiece != null) { + onPiece(readInt32(payload, 0), readInt32(payload, 4), + payload.sub(8, payload.length - 8)); + } + case MSG_CANCEL: + if (payload.length >= 12 && onCancel != null) { + onCancel(readInt32(payload, 0), readInt32(payload, 4), + readInt32(payload, 8)); + } + case MSG_EXTENDED: + handleExtended(payload); + case MSG_HASH_REQUEST: + if (payload.length >= 48 && onHashRequest != null) { + onHashRequest(payload); + } + case MSG_HASHES: + if (payload.length >= 48 && onHashes != null) { + onHashes(payload); + } + case MSG_HASH_REJECT: + if (payload.length >= 48 && onHashReject != null) { + onHashReject(payload); + } + default: + } + } + + function handleExtended(payload:Bytes):Void { + if (payload.length < 1) return; + var extId = payload.get(0); + var body = payload.sub(1, payload.length - 1); + if (extId == 0) { + var dict:Dynamic = null; + try { + dict = Bencode.decode(body); + } catch(e:Dynamic) { + return; + } + peerExtendedHandshake = dict; + var m:Dynamic = Reflect.field(dict, "m"); + if (m != null) { + for (name in Reflect.fields(m)) { + extIds.set(name, Std.int(Reflect.field(m, name))); + } + } + if (onExtendedHandshake != null) onExtendedHandshake(dict); + } else { + var name = localExtIds.get(extId); + if (name != null) { + if (onExtended != null) onExtended(name, body); + } + } + } + + function sendExtendedHandshake():Void { + var m:Dynamic = {}; + var id = 1; + for (name in localExtNames) { + Reflect.setField(m, name, id); + localExtIds.set(id, name); + id++; + } + var dict:Dynamic = {m: m, v: "Leenkx 0.1"}; + if (metaSize > 0) Reflect.setField(dict, "metadata_size", metaSize); + if (extHandshakeFields != null) { + for (f in Reflect.fields(extHandshakeFields)) { + Reflect.setField(dict, f, Reflect.field(extHandshakeFields, f)); + } + } + sendExtendedMessage(0, Bencode.encode(dict)); + } + + public var metaSize:Int = 0; + public var extHandshakeFields:Dynamic = null; + + public function sendExtended(name:String, payload:Bytes):Bool { + var id = extIds.get(name); + if (id == null) return false; + var out = new haxe.io.BytesBuffer(); + out.addByte(id); + out.addBytes(payload, 0, payload.length); + sendExtendedMessage(-1, out.getBytes()); + return true; + } + + function sendExtendedMessage(id:Int, payload:Bytes):Void { + var out = new haxe.io.BytesBuffer(); + if (id >= 0) out.addByte(id); + out.addBytes(payload, 0, payload.length); + sendMessage(MSG_EXTENDED, out.getBytes()); + } + + var keepAliveBuf:Bytes = Bytes.alloc(4); + + public function sendKeepAlive():Void { + channel.send(keepAliveBuf); + } + + public function sendChoke():Void { + amChoking = true; + sendMessage(MSG_CHOKE, null); + } + public function sendUnchoke():Void { + amChoking = false; + sendMessage(MSG_UNCHOKE, null); + } + public function sendInterested():Void { + amInterested = true; + sendMessage(MSG_INTERESTED, null); + } + public function sendNotInterested():Void { + amInterested = false; + sendMessage(MSG_NOT_INTERESTED, null); + } + + public function sendHave(index:Int):Void { + var out = Bytes.alloc(9); + writeInt32(out, 0, 5); + out.set(4, MSG_HAVE); + writeInt32(out, 5, index); + channel.send(out); + } + + public function sendBitfield(bitfield:Bytes):Void { + sendMessage(MSG_BITFIELD, bitfield); + } + + public function sendRequest(index:Int, begin:Int, length:Int):Void { + var out = Bytes.alloc(17); + writeInt32(out, 0, 13); + out.set(4, MSG_REQUEST); + writeInt32(out, 5, index); + writeInt32(out, 9, begin); + writeInt32(out, 13, length); + channel.send(out); + } + + public function sendPiece(index:Int, begin:Int, data:Bytes):Void { + var total = 13 + data.length; + var out = Bytes.alloc(total); + writeInt32(out, 0, total - 4); + out.set(4, MSG_PIECE); + writeInt32(out, 5, index); + writeInt32(out, 9, begin); + out.blit(13, data, 0, data.length); + channel.send(out); + } + + static function hashMsg(root:Bytes, base:Int, index:Int, length:Int, proofs:Int, ?hashes:Bytes):Bytes { + var n = 48 + (hashes != null ? hashes.length : 0); + var out = Bytes.alloc(n); + out.blit(0, root, 0, 32); + writeInt32(out, 32, base); + writeInt32(out, 36, index); + writeInt32(out, 40, length); + writeInt32(out, 44, proofs); + if (hashes != null) out.blit(48, hashes, 0, hashes.length); + return out; + } + + public function sendHashRequest(root:Bytes, base:Int, index:Int, length:Int, proofs:Int):Void { + sendMessage(MSG_HASH_REQUEST, + hashMsg(root, base, index, length, proofs)); + } + + public function sendHashes(root:Bytes, base:Int, index:Int, length:Int, proofs:Int, hashes:Bytes):Void { + sendMessage(MSG_HASHES, + hashMsg(root, base, index, length, proofs, hashes)); + } + + public function sendHashReject(root:Bytes, base:Int, index:Int, length:Int, proofs:Int):Void { + sendMessage(MSG_HASH_REJECT, + hashMsg(root, base, index, length, proofs)); + } + + public function sendCancel(index:Int, begin:Int, length:Int):Void { + var out = Bytes.alloc(17); + writeInt32(out, 0, 13); + out.set(4, MSG_CANCEL); + writeInt32(out, 5, index); + writeInt32(out, 9, begin); + writeInt32(out, 13, length); + channel.send(out); + } + + function sendMessage(id:Int, payload:Bytes):Void { + if (closed) return; + var len = 1 + (payload != null ? payload.length : 0); + var total = 4 + 1 + (payload != null ? payload.length : 0); + var out = Bytes.alloc(total); + writeInt32(out, 0, len); + out.set(4, id); + if (payload != null) out.blit(5, payload, 0, payload.length); + channel.send(out); + } + + public function close():Void { + closed = true; + channel.close(); + } + + function fail(reason:String):Void { + if (onError != null) onError(reason); + close(); + } + + static function bytesEqual(a:Bytes, b:Bytes):Bool { + if (a.length != b.length) return false; + for (i in 0...a.length) { + if (a.get(i) != b.get(i)) return false; + } + return true; + } + + static function readInt32(b:Bytes, off:Int):Int { + return (b.get(off) << 24) | (b.get(off + 1) << 16) + | (b.get(off + 2) << 8) | b.get(off + 3); + } + + static public function readU32(b:Bytes, off:Int):Int { + return readInt32(b, off); + } + + static function writeInt32(b:Bytes, off:Int, v:Int):Void { + b.set(off, (v >> 24) & 0xFF); + b.set(off + 1, (v >> 16) & 0xFF); + b.set(off + 2, (v >> 8) & 0xFF); + b.set(off + 3, v & 0xFF); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/storage/Storage.hx b/leenkx/Sources/leenkx/network/torrent/storage/Storage.hx new file mode 100644 index 00000000..84f44077 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/storage/Storage.hx @@ -0,0 +1,145 @@ +package leenkx.network.torrent.storage; + +import haxe.io.Bytes; + +interface IStorage { + function write(offset:Int, data:Bytes):Void; + function read(offset:Int, length:Int):Bytes; + function flush():Void; + function close():Void; + function deleteData():Void; +} + +class MemoryStorage implements IStorage { + var data:Bytes; + + public function new(totalLength:Int) { + data = Bytes.alloc(totalLength); + } + + public function write(offset:Int, chunk:Bytes):Void { + data.blit(offset, chunk, 0, chunk.length); + } + + public function read(offset:Int, length:Int):Bytes { + return data.sub(offset, length); + } + + public function flush():Void {} + public function close():Void {} + public function deleteData():Void { + data = null; + } +} + +#if (sys || kha_krom) +class FileTreeStorage implements IStorage { + var dir:String; + var files:Array<{path:String, length:Int, offset:Int}>; + #if kha_krom + var madeDirs = new Map(); + #end + + public function new(dir:String, files:Array<{path:String, length:Int, offset:Int}>) { + this.dir = dir; + this.files = files; + } + + function filePath(f:{path:String, length:Int, offset:Int}):String { + return dir + "/" + + leenkx.network.torrent.MetaInfo.sanitizePath(f.path); + } + + function writeChunk(f:{path:String, length:Int, offset:Int}, foffset:Int, chunk:Bytes):Void { + var p = filePath(f); + #if kha_krom + var i = Std.int(Math.max(p.lastIndexOf("/"), + p.lastIndexOf("\\"))); + if (i > 0) { + var d = p.substr(0, i); + if (!madeDirs.exists(d)) { + madeDirs.set(d, true); + var win = Krom.systemId() == "Windows"; + Krom.sysCommand(win + ? "mkdir \"" + StringTools.replace(d, "/", "\\") + "\"" + : "mkdir -p \"" + d + "\""); + } + } + try { + Krom.fileWriteBytes(p, chunk.getData(), foffset, + chunk.length); + } catch(e:Dynamic) { + trace('[FileTreeStorage] WRITE FAILED ' + p + + ' off=' + foffset + ' len=' + chunk.length + + ' err=' + Std.string(e)); + return; + } + #else + var i = Std.int(Math.max(p.lastIndexOf("/"), + p.lastIndexOf("\\"))); + if (i > 0) { + var d = p.substr(0, i); + if (!sys.FileSystem.exists(d)) sys.FileSystem.createDirectory(d); + } + var out = sys.io.File.update(p, true); + out.seek(foffset, sys.io.FileSeek.SeekBegin); + out.writeBytes(chunk, 0, chunk.length); + out.close(); + #end + } + + function readChunk(f:{path:String, length:Int, offset:Int}, foffset:Int, len:Int):Bytes { + var p = filePath(f); + #if kha_krom + return Bytes.ofData(Krom.fileReadBytes(p, foffset, len)); + #else + var buf = Bytes.alloc(len); + if (sys.FileSystem.exists(p)) { + var inp = sys.io.File.read(p, true); + inp.seek(foffset, sys.io.FileSeek.SeekBegin); + try inp.readBytes(buf, 0, len) catch(e:haxe.io.Eof) {} + inp.close(); + } + return buf; + #end + } + + public function write(offset:Int, data:Bytes):Void { + for (f in files) { + var lo = offset > f.offset ? offset : f.offset; + var hi = offset + data.length < f.offset + f.length + ? offset + data.length : f.offset + f.length; + if (lo >= hi) continue; + writeChunk(f, lo - f.offset, + data.sub(lo - offset, hi - lo)); + } + } + + public function read(offset:Int, length:Int):Bytes { + var out = Bytes.alloc(length); + for (f in files) { + var lo = offset > f.offset ? offset : f.offset; + var hi = offset + length < f.offset + f.length + ? offset + length : f.offset + f.length; + if (lo >= hi) continue; + var chunk = readChunk(f, lo - f.offset, hi - lo); + out.blit(lo - offset, chunk, 0, chunk.length); + } + return out; + } + + public function flush():Void {} + public function close():Void {} + + public function deleteData():Void { + for (f in files) { + var p = filePath(f); + #if sys + if (sys.FileSystem.exists(p)) sys.FileSystem.deleteFile(p); + #else + Krom.deleteFile(p); + #end + } + } +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/tracker/HttpTracker.hx b/leenkx/Sources/leenkx/network/torrent/tracker/HttpTracker.hx new file mode 100644 index 00000000..382731b6 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/tracker/HttpTracker.hx @@ -0,0 +1,555 @@ +package leenkx.network.torrent.tracker; + +import haxe.io.Bytes; +import leenkx.network.torrent.Bencode; +import leenkx.network.torrent.Crypto; +import leenkx.network.torrent.TorrentInfo; + +#if (sys || kha_krom) +class HttpTracker { + public var onPeers:Array<{host:String, port:Int}> -> Void; + public var onInterval:Int -> Void; + public var onError:String -> Void; + public var statsProvider:Void -> {downloaded:Float, uploaded:Float, + left:Float} = null; + + var url:String; + var infoHash:Bytes; + var peerId:Bytes; + var port:Int; + var intervalSec:Int = 1800; + var numwant:Int = 50; + var timer:haxe.Timer = null; + var destroyed = false; + var busy = false; + var key:Int = Std.random(0x7FFFFFFF); + var trackerId:String = null; + + public function new(url:String, infoHash:Bytes, peerId:Bytes, port:Int = 6881) { + this.url = url; + this.infoHash = infoHash; + this.peerId = peerId; + this.port = port; + } + + public function start():Void { + announce("started"); + } + + public function stop():Void { + destroyed = true; + if (timer != null) { + timer.stop(); + timer = null; + } + announce("stopped"); + } + + public function announceEvent(event:String):Void { + announce(event); + } + + function announce(event:String):Void { + if (busy) return; + busy = true; + var stats = statsProvider != null ? statsProvider() + : {downloaded: 0.0, uploaded: 0.0, left: 0.0}; + var sep = url.indexOf("?") >= 0 ? "&" : "?"; + var req = url + sep + + "info_hash=" + percentEncode(infoHash) + + "&peer_id=" + percentEncode(peerId) + + "&port=" + port + + "&uploaded=" + Std.int(stats.uploaded) + + "&downloaded=" + Std.int(stats.downloaded) + + "&left=" + Std.int(stats.left) + + "&compact=1&numwant=" + numwant + "&key=" + + StringTools.hex(key, 8); + if (trackerId != null) req += "&trackerid=" + trackerId; + if (event != null) req += "&event=" + event; + var self = this; + var finish = function(body:Bytes, err:String) { + self.busy = false; + if (self.destroyed) return; + if (err != null) { + if (self.onError != null) self.onError(err); + self.schedule(); + return; + } + self.parseResponse(body); + self.schedule(); + }; + #if sys + var watchdog = new haxe.Timer(35000); + watchdog.run = function() { + watchdog.stop(); + var s = self.currentSock; + if (s != null) try s.close() catch(e:Dynamic) {} + }; + sys.thread.Thread.create(function() { + self.httpGet(req, function(body:Bytes, err:String) { + leenkx.network.torrent.TorrentClient.postMain( + function() { + watchdog.stop(); + finish(body, err); + }); + }); + }); + #else + httpGet(req, finish); + #end + } + + function schedule():Void { + if (destroyed) return; + if (timer != null) timer.stop(); + timer = new haxe.Timer(intervalSec * 1000); + timer.run = function() { + timer.stop(); + announce(null); + }; + } + + function parseResponse(body:Bytes):Void { + var dict:Dynamic; + try { + dict = Bencode.decode(body); + } catch(e:Dynamic) { + if (onError != null) onError("bad tracker response"); + return; + } + var failure = Reflect.field(dict, "failure reason"); + if (failure != null) { + if (onError != null) onError(Std.string(failure)); + return; + } + var warning = Reflect.field(dict, "warning message"); + if (warning != null && onError != null) { + onError("warning: " + Std.string(warning)); + } + var tid = Reflect.field(dict, "tracker id"); + if (tid != null) trackerId = Std.string(tid); + var iv = Reflect.field(dict, "interval"); + if (iv == null) iv = Reflect.field(dict, "min interval"); + if (iv != null) { + var ivF = Std.parseFloat(Std.string(iv)); + if (!Math.isNaN(ivF) && ivF > 0) intervalSec = Std.int(ivF); + } + var peersField = Reflect.field(dict, "peers"); + var peers:Array<{host:String, port:Int}> = []; + if (Std.isOfType(peersField, Bytes)) { + peers = UdpTracker.parsePeers((peersField : Bytes), 0); + } else if (Std.isOfType(peersField, Array)) { + var arr:Array = cast peersField; + for (pd in arr) { + var ip = Reflect.field(pd, "ip"); + var prt = Reflect.field(pd, "port"); + if (ip != null && prt != null) { + peers.push({ + host: Std.isOfType(ip, Bytes) + ? (ip:Bytes).toString() : Std.string(ip), + port: Std.int(Std.parseFloat(Std.string(prt))) + }); + } + } + } + if (onInterval != null) onInterval(intervalSec); + if (peers.length > 0 && onPeers != null) onPeers(peers); + } + + function httpGet(reqUrl:String, cb:Bytes -> String -> Void):Void { + var rest = reqUrl.substr(reqUrl.indexOf("://") + 3); + var secure = reqUrl.indexOf("https://") == 0; + var slash = rest.indexOf("/"); + var hostPort = slash >= 0 ? rest.substr(0, slash) : rest; + var path = slash >= 0 ? rest.substr(slash) : "/"; + var colon = hostPort.indexOf(":"); + var host = colon >= 0 ? hostPort.substr(0, colon) : hostPort; + var port = colon >= 0 + ? Std.parseInt(hostPort.substr(colon + 1)) + : (secure ? 443 : 80); + + #if kha_krom + if (secure) { + cb(null, "https trackers not supported on krom"); + return; + } + var sock = new leenkx.network.krom.KromSocket(); + sock.setBlocking(false); + var connectFailed = false; + try { + sock.connect( + new leenkx.network.krom.KromSocket.KromHost(host), port); + } catch(e:Dynamic) { + connectFailed = true; + } + if (connectFailed) { + cb(null, "connect failed"); + return; + } + var request = "GET " + path + " HTTP/1.0\r\nHost: " + hostPort + + "\r\nConnection: close\r\n\r\n"; + var reqBytes = Bytes.ofString(request); + var buf = new haxe.io.BytesBuffer(); + var deadline = haxe.Timer.stamp() + 15; + var sentReq = false; + var tick:Void -> Void = null; + tick = function() { + if (haxe.Timer.stamp() > deadline) { + leenkx.network.krom.KromSocket.KromPump.remove(tick); + sock.close(); + cb(null, "http tracker timeout"); + return; + } + if (!sock.isConnected()) return; + if (!sentReq) { + sock.sendRaw(reqBytes); + sentReq = true; + return; + } + try { + var chunk = sock.recvRaw(65536); + if (chunk == null) return; + if (chunk.length == 0) { + leenkx.network.krom.KromSocket.KromPump.remove(tick); + sock.close(); + finishResponse(buf.getBytes(), cb); + return; + } + buf.addBytes(chunk, 0, chunk.length); + } catch(e:Dynamic) { + leenkx.network.krom.KromSocket.KromPump.remove(tick); + sock.close(); + finishResponse(buf.getBytes(), cb); + } + }; + leenkx.network.krom.KromSocket.KromPump.add(tick); + #else + var sock:sys.net.Socket; + if (secure) { + #if hl + sock = new sys.ssl.Socket(); + #else + cb(null, "https trackers not supported on this target"); + return; + #end + } else { + sock = new sys.net.Socket(); + } + sock.setTimeout(30); + try { + sock.connect(new sys.net.Host(host), port); + currentSock = sock; + var request = "GET " + path + " HTTP/1.0\r\nHost: " + hostPort + + "\r\nConnection: close\r\n\r\n"; + sock.output.write(Bytes.ofString(request)); + sock.output.flush(); + var buf = new haxe.io.BytesBuffer(); + while (true) { + var chunk = Bytes.alloc(65536); + var read = 0; + try { + read = sock.input.readBytes(chunk, 0, chunk.length); + } catch(eof:haxe.io.Eof) { + break; + } + if (read <= 0) break; + buf.addBytes(chunk, 0, read); + } + sock.close(); + currentSock = null; + finishResponse(buf.getBytes(), cb); + } catch(e:Dynamic) { + try sock.close() catch(_) {} + currentSock = null; + cb(null, Std.string(e)); + } + #end + } + + #if sys + var currentSock:sys.net.Socket = null; + #end + + function finishResponse(raw:Bytes, cb:Bytes -> String -> Void):Void { + var body:Bytes = null; + for (i in 0...raw.length - 3) { + if (raw.get(i) == 0x0D && raw.get(i + 1) == 0x0A + && raw.get(i + 2) == 0x0D && raw.get(i + 3) == 0x0A) { + body = raw.sub(i + 4, raw.length - i - 4); + break; + } + } + if (body == null) { + cb(null, "bad http response"); + return; + } + cb(body, null); + } + + static function percentEncode(b:Bytes):String { + var sb = new StringBuf(); + var safe = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz-_.~"; + for (i in 0...b.length) { + var c = b.get(i); + if (safe.indexOf(String.fromCharCode(c)) >= 0) { + sb.addChar(c); + } else { + sb.add("%"); + sb.add(StringTools.hex(c, 2)); + } + } + return sb.toString(); + } +} +class HttpTrackerServer { + public var onLog:String -> Void = null; + public var onError:String -> Void = null; + + var swarm = new UdpTracker.TrackerSwarm(); + var running = false; + #if sys + var sock:sys.net.Socket; + #elseif kha_krom + var sock:leenkx.network.krom.KromSocket; + var timer:haxe.Timer = null; + var pending:Array<{sock:leenkx.network.krom.KromSocket, + buf:haxe.io.BytesBuffer}> = []; + #end + + public function new() {} + + public function start(port:Int):Void { + if (running) return; + running = true; + #if sys + sock = new sys.net.Socket(); + sock.bind(new sys.net.Host("127.0.0.1"), port); + sock.listen(64); + var self = this; + sys.thread.Thread.create(function() self.loop()); + #elseif kha_krom + sock = new leenkx.network.krom.KromSocket(); + sock.bind(new leenkx.network.krom.KromSocket.KromHost( + "127.0.0.1"), port); + sock.listen(64); + sock.setBlocking(false); + timer = new haxe.Timer(5); + timer.run = pump; + #end + log("http tracker listening on 127.0.0.1:" + port); + } + + public function stop():Void { + running = false; + #if kha_krom + if (timer != null) { + timer.stop(); + timer = null; + } + for (p in pending) try p.sock.close() catch (_) {} + pending = []; + #end + if (sock != null) { + try sock.close() catch (e:Dynamic) {} + } + } + + #if sys + function loop():Void { + while (running) { + try { + var c = sock.accept(); + if (c != null) serve(c); + } catch (e:Dynamic) { + if (running) err("accept: " + Std.string(e)); + } + } + } + #elseif kha_krom + function pump():Void { + if (!running || sock == null) return; + while (true) { + var c:leenkx.network.krom.KromSocket = null; + try { + c = sock.accept(); + } catch (e:Dynamic) { + break; + } + if (c == null) break; + c.setBlocking(false); + pending.push({sock: c, buf: new haxe.io.BytesBuffer()}); + } + var i = pending.length; + while (i-- > 0) { + var p = pending[i]; + var drop = false; + try { + var d = p.sock.recvRaw(65536); + if (d != null) p.buf.addBytes(d, 0, d.length); + } catch (e:Dynamic) { + drop = true; + } + var done = !drop && find(p.buf.getBytes(), + "\r\n\r\n") >= 0; + if (done) { + var body = handleRequest(p.buf.getBytes(), + "127.0.0.1"); + try { + var head = "HTTP/1.0 200 OK\r\n" + + "Content-Length: " + body.length + + "\r\n\r\n"; + p.sock.sendRaw(Bytes.ofString(head)); + p.sock.sendRaw(body); + } catch (e:Dynamic) {} + } + if (done || drop || p.buf.length > 65536) { + try p.sock.close() catch (e:Dynamic) {} + pending.splice(i, 1); + } + } + } + #end + + #if sys + function serve(c:sys.net.Socket):Void { + try { + var raw = new haxe.io.BytesBuffer(); + var tmp = Bytes.alloc(4096); + while (find(raw.getBytes(), "\r\n\r\n") < 0) { + var n = c.input.readBytes(tmp, 0, tmp.length); + if (n <= 0) break; + raw.addBytes(tmp, 0, n); + if (raw.length > 65536) break; + } + var body = handleRequest(raw.getBytes(), + c.peer().host.toString()); + reply(c, 200, body); + } catch (e:Dynamic) { + try c.close() catch (_) {} + } + } + + function reply(c:sys.net.Socket, code:Int, body:Bytes):Void { + var head = "HTTP/1.0 " + code + " OK\r\nContent-Length: " + + body.length + "\r\n\r\n"; + c.output.write(Bytes.ofString(head)); + c.output.write(body); + c.output.flush(); + c.close(); + } + #end + + function handleRequest(req:Bytes, peerHost:String):Bytes { + var lineEnd = find(req, "\r\n"); + var line = lineEnd > 0 + ? req.sub(0, lineEnd).toString() : ""; + var q = line.indexOf("?"); + var sp2 = line.indexOf(" HTTP"); + if (q < 0 || sp2 < 0) { + return Bytes.ofString("bad request"); + } + var params = decodeQuery(req.sub(q + 1, sp2 - q - 1)); + var infoHash = params.get("info_hash"); + var peerId = params.get("peer_id"); + if (infoHash == null || peerId == null) { + return Bencode.encode({ + "failure reason": "missing params"}); + } + var port = Std.parseInt(textParam(params, "port")); + var left = Std.parseFloat(textParam(params, "left")); + var numwant = Std.parseInt(textParam(params, "numwant")); + if (numwant == null || numwant <= 0 || numwant > 200) { + numwant = 50; + } + var ipText = textParam(params, "ip"); + var host = ipText != null && ipText != "" + ? ipText : peerHost; + var ev = textParam(params, "event"); + var res = swarm.announce(infoHash, peerId, host, + port != null ? port : 6881, + Math.isNaN(left) ? 0.0 : left, + ev == "stopped", numwant); + log("announce " + Crypto.toHex(infoHash).substr(0, 8) + + " peer=" + host + ":" + port + " event=" + ev + + " swarm=" + res.complete + "s/" + + res.incomplete + "l reply=" + res.peers.length); + var compact = new haxe.io.BytesBuffer(); + UdpTracker.writePeers(compact, res.peers); + return Bencode.encode({ + interval: swarm.intervalSec, + complete: res.complete, + incomplete: res.incomplete, + "tracker id": "leenkx-local", + peers: compact.getBytes() + }); + } + + static function find(b:Bytes, s:String):Int { + var n = s.length; + for (i in 0...b.length - n + 1) { + var ok = true; + for (j in 0...n) { + if (b.get(i + j) != s.charCodeAt(j)) { + ok = false; + break; + } + } + if (ok) return i; + } + return -1; + } + + static function textParam(p:Map, k:String):String { + var b = p.get(k); + return b != null ? b.toString() : null; + } + + static function decodeQuery(raw:Bytes):Map { + var out = new Map(); + var s = raw.toString(); + for (pair in s.split("&")) { + var eq = pair.indexOf("="); + var k = eq >= 0 ? pair.substr(0, eq) : pair; + var v = eq >= 0 ? pair.substr(eq + 1) : ""; + out.set(StringTools.urlDecode(k), pctDecode(v)); + } + return out; + } + + static function pctDecode(s:String):Bytes { + var out = new haxe.io.BytesBuffer(); + var i = 0; + while (i < s.length) { + var c = s.charCodeAt(i); + if (c == 0x25 && i + 2 < s.length) { + out.addByte(Std.parseInt("0x" + s.substr(i + 1, 2))); + i += 3; + } else if (c == 0x2B) { + out.addByte(0x20); + i++; + } else { + out.addByte(c & 0xff); + i++; + } + } + return out.getBytes(); + } + + function log(m:String):Void { + if (onLog != null) { + var f = onLog; + leenkx.network.torrent.TorrentClient.postMain( + function() f(m)); + } + } + + function err(m:String):Void { + if (onError != null) { + var f = onError; + leenkx.network.torrent.TorrentClient.postMain( + function() f(m)); + } + } +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/tracker/UdpTracker.hx b/leenkx/Sources/leenkx/network/torrent/tracker/UdpTracker.hx new file mode 100644 index 00000000..e99cb536 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/tracker/UdpTracker.hx @@ -0,0 +1,696 @@ +package leenkx.network.torrent.tracker; + +import haxe.io.Bytes; +import leenkx.network.torrent.Crypto; + +#if (sys || kha_krom) + +typedef TrackerPeer = { + peerId:Bytes, + host:String, + port:Int, + left:Float, + stamp:Float +} + +typedef SwarmResult = { + complete:Int, + incomplete:Int, + peers:Array +} + +class TrackerSwarm { + public var intervalSec:Int = 30; + + var swarms:Map> = []; + + public function new() {} + + public function announce(infoHash:Bytes, peerId:Bytes, host:String, port:Int, left:Float, stopped:Bool, numwant:Int):SwarmResult { + var key = Crypto.toHex(infoHash); + var swarm = swarms.get(key); + if (swarm == null) { + swarm = new Map(); + swarms.set(key, swarm); + } + var pid = Crypto.toHex(peerId); + if (stopped) { + swarm.remove(pid); + } else { + swarm.set(pid, { + peerId: peerId, + host: host, + port: port, + left: left, + stamp: haxe.Timer.stamp() + }); + } + var peers:Array = []; + var complete = 0; + var incomplete = 0; + for (p in swarm) { + if (p.left <= 0) complete++; else incomplete++; + if (pid == Crypto.toHex(p.peerId)) continue; + if (peers.length < numwant) peers.push(p); + } + return {complete: complete, incomplete: incomplete, + peers: peers}; + } +} + +class UdpTracker { + static inline var PROTOCOL_ID_HI:Int = 0x41727101; + static inline var PROTOCOL_ID_LO:Int = 0x80; + public static inline var ACTION_CONNECT:Int = 0; + public static inline var ACTION_ANNOUNCE:Int = 1; + static inline var ACTION_SCRAPE:Int = 2; + public static inline var ACTION_ERROR:Int = 3; + public static inline var EVENT_COMPLETED:Int = 1; + public static inline var EVENT_STARTED:Int = 2; + public static inline var EVENT_STOPPED:Int = 3; + + public var onPeers:Array<{host:String, port:Int}> -> Void; + public var onInterval:Int -> Void; + public var onError:String -> Void; + public var statsProvider:Void -> {downloaded:Float, uploaded:Float, + left:Float} = null; + + var url:String; + var infoHash:Bytes; + var peerId:Bytes; + var port:Int; + var intervalSec:Int = 1800; + var numwant:Int = 50; + var timer:haxe.Timer = null; + var destroyed = false; + var inFlight = false; + var key:Int = Std.random(0x7FFFFFFF); + var connIdHi:Int = 0; + var connIdLo:Int = 0; + var connIdTime:Float = 0; + + public function new(url:String, infoHash:Bytes, peerId:Bytes, port:Int = 6881) { + this.url = url; + this.infoHash = infoHash; + this.peerId = peerId; + this.port = port; + } + + public function start():Void { + announce(EVENT_STARTED); + } + + public function stop():Void { + if (destroyed) return; + announce(EVENT_STOPPED); + destroyed = true; + if (timer != null) { + timer.stop(); + timer = null; + } + } + + public function announceEvent(event:String):Void { + var code = switch (event) { + case "completed": EVENT_COMPLETED; + case "started": EVENT_STARTED; + case "stopped": EVENT_STOPPED; + default: 0; + }; + announce(code); + } + + function announce(event:Int):Void { + if (inFlight) return; + inFlight = true; + #if kha_krom + kromAnnounce(event); + return; + #end + #if sys + var self = this; + var watchdog = new haxe.Timer(35000); + watchdog.run = function() { + watchdog.stop(); + if (self.currentSock != null) { + try self.currentSock.close() catch(e:Dynamic) {} + } + }; + sys.thread.Thread.create(function() { + var result = try { + self.doAnnounce(event); + } catch(e:Dynamic) { + {peers: [], interval: 0, error: Std.string(e)}; + } + leenkx.network.torrent.TorrentClient.postMain(function() { + watchdog.stop(); + self.inFlight = false; + if (self.destroyed) return; + if (result.error != null) { + if (self.onError != null) self.onError(result.error); + } else { + if (result.interval > 0) { + self.intervalSec = result.interval; + } + if (self.onInterval != null) { + self.onInterval(self.intervalSec); + } + if (result.peers.length > 0 && self.onPeers != null) { + self.onPeers(result.peers); + } + } + self.schedule(); + }); + }); + #end + } + + #if kha_krom + var ksock:leenkx.network.krom.KromUdpSocket = null; + var kHost:String = null; + var kPort:Int = 0; + var kTick:Void -> Void = null; + var kAttempt:Int = 0; + var kDeadline:Float = 0; + var kState:Int = 0; + var kTxId:Int = 0; + var kEvent:Int = 0; + var kLastError:String = "no response"; + + function kromAnnounce(event:Int):Void { + var rest = url.substr(url.indexOf("://") + 3); + var slash = rest.indexOf("/"); + var hostPort = slash >= 0 ? rest.substr(0, slash) : rest; + var colon = hostPort.lastIndexOf(":"); + kHost = colon >= 0 ? hostPort.substr(0, colon) : hostPort; + kPort = colon >= 0 + ? Std.parseInt(hostPort.substr(colon + 1)) : 80; + if (kPort == null) kPort = 80; + + kEvent = event; + kAttempt = 0; + try { + if (ksock != null) ksock.close(); + ksock = new leenkx.network.krom.KromUdpSocket(); + } catch(e:Dynamic) { + finishAnnounce([], 0, "udp socket: " + Std.string(e)); + return; + } + kromRound(); + } + + function kromRound():Void { + kAttempt++; + kDeadline = haxe.Timer.stamp() + 15; + if (connIdTime == 0 + || haxe.Timer.stamp() - connIdTime > 45) { + kTxId = Std.random(0x7FFFFFFF); + var connReq = new haxe.io.BytesBuffer(); + writeInt64(connReq, PROTOCOL_ID_HI, PROTOCOL_ID_LO); + writeInt32(connReq, ACTION_CONNECT); + writeInt32(connReq, kTxId); + if (kSend(connReq.getBytes()) < 16) { + finishAnnounce([], 0, "connect send failed"); + return; + } + kState = 1; + } else { + if (!kSendAnnounce()) return; + kState = 2; + } + startKTimer(); + } + + function kSendAnnounce():Bool { + var stats = statsProvider != null ? statsProvider() + : {downloaded: 0.0, uploaded: 0.0, left: 0.0}; + kTxId = Std.random(0x7FFFFFFF); + var ann = new haxe.io.BytesBuffer(); + writeInt32(ann, connIdHi); + writeInt32(ann, connIdLo); + writeInt32(ann, ACTION_ANNOUNCE); + writeInt32(ann, kTxId); + ann.addBytes(infoHash, 0, 20); + ann.addBytes(peerId, 0, 20); + writeInt64v(ann, stats.downloaded); + writeInt64v(ann, stats.left); + writeInt64v(ann, stats.uploaded); + writeInt32(ann, kEvent); + writeInt32(ann, 0); + writeInt32(ann, key); + writeInt32(ann, numwant); + var portBytes = Bytes.alloc(2); + portBytes.set(0, (this.port >> 8) & 0xFF); + portBytes.set(1, this.port & 0xFF); + ann.addBytes(portBytes, 0, 2); + if (kSend(ann.getBytes()) < 98) { + finishAnnounce([], 0, "announce send failed"); + return false; + } + return true; + } + + function kSend(pkt:Bytes):Int { + try { + return ksock.sendTo(pkt, 0, pkt.length, + new leenkx.network.krom.KromSocket.KromHost(kHost), + kPort); + } catch(e:Dynamic) { + return -1; + } + } + + function startKTimer():Void { + if (kTick == null) { + kTick = kromPoll; + leenkx.network.krom.KromSocket.KromPump.add(kTick); + } + } + + function kromPoll():Void { + if ((destroyed && kEvent != EVENT_STOPPED) || ksock == null) { + kromCleanup(); + inFlight = false; + return; + } + var pkt:{data:Bytes, host:String, port:Int} = null; + try { + pkt = ksock.recvFrom(65536); + } catch(e:Dynamic) { + finishAnnounce([], 0, "recv: " + Std.string(e)); + return; + } + if (pkt != null) { + var resp = pkt.data; + if (kState == 1 && resp.length >= 16 + && readInt32(resp, 0) == ACTION_CONNECT + && readInt32(resp, 4) == kTxId) { + connIdHi = readInt32(resp, 8); + connIdLo = readInt32(resp, 12); + connIdTime = haxe.Timer.stamp(); + if (!kSendAnnounce()) return; + kState = 2; + kDeadline = haxe.Timer.stamp() + 15; + } else if (kState == 2 && resp.length >= 20) { + var action = readInt32(resp, 0); + if (action == ACTION_ERROR) { + finishAnnounce([], 0, "tracker error: " + + resp.sub(8, resp.length - 8).toString()); + return; + } + if (action == ACTION_ANNOUNCE + && readInt32(resp, 4) == kTxId) { + var interval = readInt32(resp, 8); + finishAnnounce(parsePeers(resp), interval, null); + return; + } + } + } + if (haxe.Timer.stamp() > kDeadline) { + kLastError = kState == 1 ? "connect timeout" + : "announce timeout"; + if (kAttempt < 3) { + connIdTime = 0; + kromRound(); + } else { + finishAnnounce([], 0, kLastError); + } + } + } + + function finishAnnounce(peers:Array<{host:String, port:Int}>, interval:Int, error:String):Void { + kromCleanup(); + inFlight = false; + if (destroyed) return; + if (error != null) { + if (onError != null) onError(error); + } else { + if (interval > 0) intervalSec = interval; + if (onInterval != null) onInterval(intervalSec); + if (peers.length > 0 && onPeers != null) onPeers(peers); + } + schedule(); + } + + function kromCleanup():Void { + if (kTick != null) { + leenkx.network.krom.KromSocket.KromPump.remove(kTick); + kTick = null; + } + if (ksock != null) { + try ksock.close() catch(e:Dynamic) {} + ksock = null; + } + } + #end + + function schedule():Void { + if (destroyed) return; + if (timer != null) timer.stop(); + timer = new haxe.Timer(intervalSec * 1000); + timer.run = function() { + timer.stop(); + announce(0); + }; + } + + #if sys + function doAnnounce(event:Int):{peers:Array<{host:String, port:Int}>, + interval:Int, error:String} { + var empty = {peers: [], interval: 0, error: null}; + var rest = url.substr(url.indexOf("://") + 3); + var slash = rest.indexOf("/"); + var hostPort = slash >= 0 ? rest.substr(0, slash) : rest; + var colon = hostPort.lastIndexOf(":"); + var host = colon >= 0 ? hostPort.substr(0, colon) : hostPort; + var port = colon >= 0 + ? Std.parseInt(hostPort.substr(colon + 1)) : 80; + if (port == null) port = 80; + + var sock = new sys.net.UdpSocket(); + var addr = new sys.net.Address(); + try { + addr.host = new sys.net.Host(host).ip; + addr.port = port; + } catch(e:Dynamic) { + return {peers: [], interval: 0, + error: "resolve: " + Std.string(e)}; + } + + var lastError = "no response"; + for (attempt in 0...3) { + var r = tryAnnounce(sock, addr, event); + if (r.error == null) { + sock.close(); + return r; + } + lastError = r.error; + connIdTime = 0; + } + sock.close(); + return {peers: [], interval: 0, error: lastError}; + } + + function tryAnnounce(sock:sys.net.UdpSocket, addr:sys.net.Address, event:Int):{peers:Array<{host:String, port:Int}>, interval:Int, error:String} { + var now = haxe.Timer.stamp(); + if (connIdTime == 0 || now - connIdTime > 45) { + var txId = Std.random(0x7FFFFFFF); + var connReq = new haxe.io.BytesBuffer(); + writeInt64(connReq, PROTOCOL_ID_HI, PROTOCOL_ID_LO); // protocol id + writeInt32(connReq, ACTION_CONNECT); + writeInt32(connReq, txId); + try { + sock.sendTo(connReq.getBytes(), 0, 16, addr); + } catch(e:Dynamic) { + return {peers: [], interval: 0, + error: "send: " + Std.string(e)}; + } + var resp = recvWithTimeout(sock, 15000); + if (resp == null || resp.length < 16) { + return {peers: [], interval: 0, error: "connect timeout"}; + } + if (readInt32(resp, 0) != ACTION_CONNECT + || readInt32(resp, 4) != txId) { + return {peers: [], interval: 0, + error: "bad connect response"}; + } + connIdHi = readInt32(resp, 8); + connIdLo = readInt32(resp, 12); + connIdTime = now; + } + + var stats = statsProvider != null ? statsProvider() + : {downloaded: 0.0, uploaded: 0.0, left: 0.0}; + + var txId = Std.random(0x7FFFFFFF); + var ann = new haxe.io.BytesBuffer(); + writeInt32(ann, connIdHi); + writeInt32(ann, connIdLo); + writeInt32(ann, ACTION_ANNOUNCE); + writeInt32(ann, txId); + ann.addBytes(infoHash, 0, 20); + ann.addBytes(peerId, 0, 20); + writeInt64v(ann, stats.downloaded); + writeInt64v(ann, stats.left); + writeInt64v(ann, stats.uploaded); + writeInt32(ann, event); + writeInt32(ann, 0); + writeInt32(ann, key); + writeInt32(ann, numwant); + var portBytes = Bytes.alloc(2); + portBytes.set(0, (this.port >> 8) & 0xFF); + portBytes.set(1, this.port & 0xFF); + ann.addBytes(portBytes, 0, 2); + try { + sock.sendTo(ann.getBytes(), 0, 98, addr); + } catch(e:Dynamic) { + return {peers: [], interval: 0, + error: "announce send: " + Std.string(e)}; + } + var resp = recvWithTimeout(sock, 15000); + if (resp == null || resp.length < 20) { + return {peers: [], interval: 0, error: "announce timeout"}; + } + if (readInt32(resp, 0) == ACTION_ERROR) { + return {peers: [], interval: 0, + error: "tracker error: " + + resp.sub(8, resp.length - 8).toString()}; + } + if (readInt32(resp, 0) != ACTION_ANNOUNCE + || readInt32(resp, 4) != txId) { + return {peers: [], interval: 0, error: "bad announce response"}; + } + var interval = readInt32(resp, 8); + return {peers: parsePeers(resp), interval: interval, + error: null}; + } + + var currentSock:sys.net.UdpSocket = null; + + function recvWithTimeout(sock:sys.net.UdpSocket, ms:Int):Bytes { + currentSock = sock; + sock.setTimeout(ms / 1000); + var buf = Bytes.alloc(65536); + var addr = new sys.net.Address(); + try { + var r = sock.readFrom(buf, 0, buf.length, addr); + if (r > 0) return buf.sub(0, r); + } catch(e:Dynamic) { + var m = Std.string(e); + if (m != "Blocked" && m != "Eof") trace("Error: " + e); + } + return null; + } + #end + + static public function parsePeers(resp:Bytes, off:Int = 20):Array<{host:String, port:Int}> { + var peers:Array<{host:String, port:Int}> = []; + while (off + 6 <= resp.length) { + peers.push({ + host: resp.get(off) + "." + resp.get(off + 1) + "." + + resp.get(off + 2) + "." + resp.get(off + 3), + port: (resp.get(off + 4) << 8) | resp.get(off + 5) + }); + off += 6; + } + return peers; + } + + static public function writePeers(buf:haxe.io.BytesBuffer, peers:Array<{host:String, port:Int}>):Void { + for (p in peers) { + for (o in p.host.split(".")) { + buf.addByte(Std.parseInt(o)); + } + buf.addByte((p.port >> 8) & 0xff); + buf.addByte(p.port & 0xff); + } + } + + static public function readInt32(b:Bytes, pos:Int):Int { + return (b.get(pos) << 24) | (b.get(pos + 1) << 16) + | (b.get(pos + 2) << 8) | b.get(pos + 3); + } + + static public function writeInt32(buf:haxe.io.BytesBuffer, v:Int):Void { + buf.addByte((v >> 24) & 0xFF); + buf.addByte((v >> 16) & 0xFF); + buf.addByte((v >> 8) & 0xFF); + buf.addByte(v & 0xFF); + } + + static function writeInt64(buf:haxe.io.BytesBuffer, hi:Int, lo:Int):Void { + writeInt32(buf, hi); + writeInt32(buf, lo); + } + + static function writeInt64v(buf:haxe.io.BytesBuffer, v:Float):Void { + if (v < 0) v = 0; + var hi = Std.int(v / 4294967296); + var lo = Std.int(v - hi * 4294967296); + writeInt32(buf, hi); + writeInt32(buf, lo); + } +} + +class UdpTrackerServer { + public var onLog:String -> Void = null; + public var onError:String -> Void = null; + + var swarm = new TrackerSwarm(); + var running = false; + #if sys + var sock:sys.net.UdpSocket; + #elseif kha_krom + var sock:leenkx.network.krom.KromUdpSocket; + var timer:haxe.Timer = null; + #end + + public function new() {} + + public function start(port:Int):Void { + if (running) return; + running = true; + #if sys + sock = new sys.net.UdpSocket(); + sock.bind(new sys.net.Host("127.0.0.1"), port); + var self = this; + sys.thread.Thread.create(function() self.loop()); + #elseif kha_krom + sock = new leenkx.network.krom.KromUdpSocket(); + sock.bind(new leenkx.network.krom.KromSocket.KromHost( + "127.0.0.1"), port); + timer = new haxe.Timer(5); + timer.run = pump; + #end + log("udp tracker listening on 127.0.0.1:" + port); + } + + public function stop():Void { + running = false; + #if kha_krom + if (timer != null) { + timer.stop(); + timer = null; + } + #end + if (sock != null) { + try sock.close() catch (e:Dynamic) {} + } + } + + #if sys + function loop():Void { + var buf = Bytes.alloc(65536); + var from = new sys.net.Address(); + while (running) { + try { + var n = sock.readFrom(buf, 0, buf.length, from); + if (n > 0) { + var resp = handle(buf.sub(0, n), + from.getHost().toString(), from.port); + if (resp != null) { + sock.sendTo(resp, 0, resp.length, from); + } + } + } catch (e:Dynamic) { + if (running) err("recv: " + Std.string(e)); + } + } + } + #elseif kha_krom + function pump():Void { + if (!running || sock == null) return; + while (true) { + var r:{data:Bytes, host:String, port:Int} = null; + try { + r = sock.recvFrom(65536); + } catch (e:Dynamic) { + err("recv: " + Std.string(e)); + break; + } + if (r == null) break; + var resp = handle(r.data, r.host, r.port); + if (resp != null) { + try { + sock.sendTo(resp, 0, resp.length, + new leenkx.network.krom.KromSocket + .KromHost(r.host), r.port); + } catch (e:Dynamic) { + err("send: " + Std.string(e)); + } + } + } + } + #end + + function handle(pkt:Bytes, host0:String, port0:Int):Bytes { + if (pkt.length < 16) return null; + var action = UdpTracker.readInt32(pkt, 8); + var tx = UdpTracker.readInt32(pkt, 12); + if (action == UdpTracker.ACTION_CONNECT) { + var r = new haxe.io.BytesBuffer(); + UdpTracker.writeInt32(r, UdpTracker.ACTION_CONNECT); + UdpTracker.writeInt32(r, tx); + UdpTracker.writeInt32(r, Std.random(0x7FFFFFFF)); + UdpTracker.writeInt32(r, Std.random(0x7FFFFFFF)); + return r.getBytes(); + } + if (action != UdpTracker.ACTION_ANNOUNCE + || pkt.length < 98) { + var r = new haxe.io.BytesBuffer(); + UdpTracker.writeInt32(r, 3); + UdpTracker.writeInt32(r, tx); + r.addString("bad announce"); + return r.getBytes(); + } + var infoHash = pkt.sub(16, 20); + var peerId = pkt.sub(36, 20); + var hi = UdpTracker.readInt32(pkt, 64); + var lo = UdpTracker.readInt32(pkt, 68); + var left = hi * 4294967296.0 + + (lo < 0 ? lo + 4294967296.0 : lo); + var event = UdpTracker.readInt32(pkt, 80); + var ipParam = UdpTracker.readInt32(pkt, 84); + var numwant = UdpTracker.readInt32(pkt, 92); + var port = (pkt.get(96) << 8) | pkt.get(97); + if (numwant <= 0 || numwant > 200) numwant = 50; + var host = host0 != null ? host0 : "127.0.0.1"; + if (ipParam != 0) { + host = ((ipParam >> 24) & 0xff) + "." + + ((ipParam >> 16) & 0xff) + "." + + ((ipParam >> 8) & 0xff) + "." + + (ipParam & 0xff); + } + var res = swarm.announce(infoHash, peerId, host, port, + left, event == 3, numwant); + log("announce " + Crypto.toHex(infoHash).substr(0, 8) + + " peer=" + host + ":" + port + " event=" + event + + " swarm=" + res.complete + "s/" + + res.incomplete + "l reply=" + res.peers.length); + var r = new haxe.io.BytesBuffer(); + UdpTracker.writeInt32(r, UdpTracker.ACTION_ANNOUNCE); + UdpTracker.writeInt32(r, tx); + UdpTracker.writeInt32(r, swarm.intervalSec); + UdpTracker.writeInt32(r, res.incomplete); + UdpTracker.writeInt32(r, res.complete); + UdpTracker.writePeers(r, res.peers); + return r.getBytes(); + } + + function log(m:String):Void { + if (onLog != null) { + var f = onLog; + leenkx.network.torrent.TorrentClient.postMain( + function() f(m)); + } + } + + function err(m:String):Void { + if (onError != null) { + var f = onError; + leenkx.network.torrent.TorrentClient.postMain( + function() f(m)); + } + } +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/transport/DatachannelTransport.hx b/leenkx/Sources/leenkx/network/torrent/transport/DatachannelTransport.hx new file mode 100644 index 00000000..e092bcec --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/DatachannelTransport.hx @@ -0,0 +1,162 @@ +package leenkx.network.torrent.transport; + +import haxe.io.Bytes; +import leenkx.network.torrent.peer.IPeerChannel; +import leenkx.network.torrent.peer.RtcPeerPool; +import leenkx.network.torrent.transport.ITransport.TransportOpts; + +class DatachannelTransport implements ITransport { + static inline var CHANNEL_LABEL = "lx_channel"; + + public var onReady:Void -> Void; + public var onPeerConnected:String -> Int -> Void; + public var onPeerDisconnected:Int -> Void; + public var onPeerData:String -> Bytes -> Void; + public var onPeerHandshake:String -> String -> Void; + public var onTrackerUpdate:Dynamic -> Void; + public var onTrackerAnnounce:Void -> Void; + public var onError:String -> Void; + + var announceUrl:String; + var iceServers:Array; + var pk:String = ""; + var ek:String = ""; + + var pool:RtcPeerPool = null; + var channels:Map = []; + var peerPks:Map = []; + + public function new(announceUrl:String, ?iceServers:Array) { + this.announceUrl = announceUrl; + this.iceServers = iceServers; + } + + public function join(identifier:String, opts:TransportOpts):Void { + var url = announceUrl; + if (opts.announce != null && opts.announce.length > 0) { + url = opts.announce[0]; + } + if (opts.iceServers != null) iceServers = opts.iceServers; + pk = opts.pk != null ? opts.pk : ""; + ek = opts.ek != null ? opts.ek : ""; + + #if hl + trace("WARNING: DatachannelTransport on HL requires -D lnx_datachannel"); + #end + + pool = new RtcPeerPool(iceServers, CHANNEL_LABEL, true, 0); + pool.onPeerChannel = onPeerChannel; + pool.onPeerLeft = onPeerLeft; + pool.onReady = function() { + if (onReady != null) onReady(); + if (onTrackerAnnounce != null) onTrackerAnnounce(); + }; + pool.onTrackerUpdate = function(d) { + if (onTrackerUpdate != null) onTrackerUpdate(d); + }; + pool.onError = function(msg) { + if (onError != null) onError(msg); + }; + pool.connect(url, opts.infoHashBinary, opts.peerIdBinary); + } + + public function broadcast(data:Bytes):Void { + for (peerId in channels.keys()) { + var ch = channels.get(peerId); + if (ch != null && ch.isOpen) { + try { + ch.send(data); + } catch(e:Dynamic) { + trace("DatachannelTransport broadcast error: " + e); + } + } + } + } + + public function sendTo(peerPk:String, data:Bytes):Bool { + for (peerId in peerPks.keys()) { + if (peerPks.get(peerId) == peerPk) { + var ch = channels.get(peerId); + if (ch != null && ch.isOpen) { + try { + ch.send(data); + return true; + } catch(e:Dynamic) { + trace("DatachannelTransport sendTo error: " + e); + } + } + return false; + } + } + return false; + } + + public function getPeerCount():Int { + return Lambda.count(channels); + } + + public function destroy(?cb:Void -> Void):Void { + if (pool != null) { + pool.close(); + pool = null; + } + channels.clear(); + peerPks.clear(); + if (cb != null) cb(); + } + + function onPeerChannel(peerId:String, ch:IPeerChannel):Void { + channels.set(peerId, ch); + var prevClose = ch.onClose; + ch.onClose = function() { + if (prevClose != null) prevClose(); + dropChannel(peerId); + }; + ch.onData = function(msg:Bytes) handleChannelMessage(peerId, msg); + sendHandshake(ch); + if (onPeerConnected != null) onPeerConnected(peerId, getPeerCount()); + } + + function onPeerLeft(peerId:String):Void { + dropChannel(peerId); + } + + function dropChannel(peerId:String):Void { + if (!channels.exists(peerId)) return; + channels.remove(peerId); + peerPks.remove(peerId); + if (onPeerDisconnected != null) { + onPeerDisconnected(getPeerCount()); + } + } + + function handleChannelMessage(peerId:String, msg:Bytes):Void { + if (msg.length > 2 && msg.get(0) == 'h'.code + && msg.get(1) == 's'.code) { + var hsStr = msg.sub(2, msg.length - 2).toString(); + try { + var hs = haxe.Json.parse(hsStr); + if (hs.pk != null) { + peerPks.set(peerId, hs.pk); + } + if (onPeerHandshake != null && hs.pk != null) { + onPeerHandshake(hs.pk, hs.ek != null ? hs.ek : ""); + } + } catch(e:Dynamic) {} + } else { + var peerPk = peerPks.get(peerId); + if (peerPk == null) peerPk = ""; + if (onPeerData != null) onPeerData(peerPk, msg); + } + } + + function sendHandshake(ch:IPeerChannel):Void { + var msg = Bytes.ofString("hs" + + haxe.Json.stringify({pk: pk, ek: ek})); + try { + ch.send(msg); + } catch(e:Dynamic) { + trace("DatachannelTransport sendHandshake error: " + e); + } + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/transport/ITransport.hx b/leenkx/Sources/leenkx/network/torrent/transport/ITransport.hx new file mode 100644 index 00000000..cfa77c4b --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/ITransport.hx @@ -0,0 +1,34 @@ +package leenkx.network.torrent.transport; + +import haxe.io.Bytes; + +typedef TransportOpts = { + @:optional var announce:Array; + @:optional var name:String; + @:optional var iceServers:Array; + @:optional var tracker:Dynamic; + @:optional var wtOpts:Dynamic; + @:optional var torrentOpts:Dynamic; + @:optional var torrent:Dynamic; + @:optional var pk:String; + @:optional var ek:String; + @:optional var infoHashBinary:String; + @:optional var peerIdBinary:String; +} + +interface ITransport { + function join(identifier:String, opts:TransportOpts):Void; + function broadcast(data:Bytes):Void; + function sendTo(peerPk:String, data:Bytes):Bool; + function getPeerCount():Int; + function destroy(?cb:Void -> Void):Void; + + var onReady:Void -> Void; + var onPeerConnected:String -> Int -> Void; + var onPeerDisconnected:Int -> Void; + var onPeerData:String -> Bytes -> Void; + var onPeerHandshake:String -> String -> Void; + var onTrackerUpdate:Dynamic -> Void; + var onTrackerAnnounce:Void -> Void; + var onError:String -> Void; +} diff --git a/leenkx/Sources/leenkx/network/torrent/transport/RtcNative.hx b/leenkx/Sources/leenkx/network/torrent/transport/RtcNative.hx new file mode 100644 index 00000000..884c34f1 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/RtcNative.hx @@ -0,0 +1,159 @@ +package leenkx.network.torrent.transport; + +#if kha_krom + +@:native("krom_rtc_init") extern function krom_rtc_init():Int; +@:native("krom_rtc_cleanup") extern function krom_rtc_cleanup():Void; +@:native("krom_rtc_create_peer_connection") extern function krom_rtc_create_peer_connection(iceServers:Array):Int; +@:native("krom_rtc_close_peer_connection") extern function krom_rtc_close_peer_connection(pc:Int):Void; +@:native("krom_rtc_delete_peer_connection") extern function krom_rtc_delete_peer_connection(pc:Int):Void; +@:native("krom_rtc_set_local_description") extern function krom_rtc_set_local_description(pc:Int, ?type:String):Void; +@:native("krom_rtc_set_remote_description") extern function krom_rtc_set_remote_description(pc:Int, sdp:String, type:String):Int; +@:native("krom_rtc_add_remote_candidate") extern function krom_rtc_add_remote_candidate(pc:Int, cand:String, mid:String):Int; +@:native("krom_rtc_get_local_description") extern function krom_rtc_get_local_description(pc:Int):String; +@:native("krom_rtc_get_local_description_type") extern function krom_rtc_get_local_description_type(pc:Int):String; +@:native("krom_rtc_create_data_channel") extern function krom_rtc_create_data_channel(pc:Int, label:String, ?unordered:Bool, ?maxRetransmits:Int):Int; +@:native("krom_rtc_delete_data_channel") extern function krom_rtc_delete_data_channel(dc:Int):Void; +@:native("krom_rtc_send_message") extern function krom_rtc_send_message(id:Int, data:Dynamic):Int; +@:native("krom_rtc_close") extern function krom_rtc_close(id:Int):Void; +@:native("krom_rtc_delete") extern function krom_rtc_delete(id:Int):Void; +@:native("krom_rtc_is_open") extern function krom_rtc_is_open(id:Int):Bool; +@:native("krom_rtc_get_buffered_amount") extern function krom_rtc_get_buffered_amount(id:Int):Int; +@:native("krom_rtc_set_local_description_callback") extern function krom_rtc_set_local_description_callback(pc:Int, fn:String -> String -> Void):Void; +@:native("krom_rtc_set_local_candidate_callback") extern function krom_rtc_set_local_candidate_callback(pc:Int, fn:String -> String -> Void):Void; +@:native("krom_rtc_set_state_change_callback") extern function krom_rtc_set_state_change_callback(pc:Int, fn:Int -> Void):Void; +@:native("krom_rtc_set_gathering_state_callback") extern function krom_rtc_set_gathering_state_callback(pc:Int, fn:Int -> Void):Void; +@:native("krom_rtc_set_data_channel_callback") extern function krom_rtc_set_data_channel_callback(pc:Int, fn:Int -> Void):Void; +@:native("krom_rtc_set_open_callback") extern function krom_rtc_set_open_callback(id:Int, fn:Void -> Void):Void; +@:native("krom_rtc_set_closed_callback") extern function krom_rtc_set_closed_callback(id:Int, fn:Void -> Void):Void; +@:native("krom_rtc_set_error_callback") extern function krom_rtc_set_error_callback(id:Int, fn:String -> Void):Void; +@:native("krom_rtc_set_message_callback") extern function krom_rtc_set_message_callback(id:Int, fn:Dynamic -> Void):Void; +@:native("krom_rtc_random_bytes") extern function krom_rtc_random_bytes(len:Int):js.lib.ArrayBuffer; + +class RtcNative { + public static inline var RTC_NEW:Int = 0; + public static inline var RTC_CONNECTING:Int = 1; + public static inline var RTC_CONNECTED:Int = 2; + public static inline var RTC_DISCONNECTED:Int = 3; + public static inline var RTC_FAILED:Int = 4; + public static inline var RTC_CLOSED:Int = 5; + + public static inline var RTC_GATHERING_NEW:Int = 0; + public static inline var RTC_GATHERING_INPROGRESS:Int = 1; + public static inline var RTC_GATHERING_COMPLETE:Int = 2; + + public static function init():Int { + return krom_rtc_init(); + } + + public static function cleanup():Void { + krom_rtc_cleanup(); + } + + public static function createPeerConnection(iceServers:Array):Int { + return krom_rtc_create_peer_connection(iceServers); + } + + public static function closePeerConnection(pc:Int):Void { + krom_rtc_close_peer_connection(pc); + } + + public static function deletePeerConnection(pc:Int):Void { + krom_rtc_delete_peer_connection(pc); + } + + public static function setLocalDescription(pc:Int, ?type:String):Void { + krom_rtc_set_local_description(pc, type); + } + + public static function setRemoteDescription(pc:Int, sdp:String, type:String):Int { + return krom_rtc_set_remote_description(pc, sdp, type); + } + + public static function addRemoteCandidate(pc:Int, cand:String, mid:String):Int { + return krom_rtc_add_remote_candidate(pc, cand, mid); + } + + public static function getLocalDescription(pc:Int):String { + return krom_rtc_get_local_description(pc); + } + + public static function getLocalDescriptionType(pc:Int):String { + return krom_rtc_get_local_description_type(pc); + } + + public static function createDataChannel(pc:Int, label:String, unordered:Bool = true, maxRetransmits:Int = 0):Int { + return krom_rtc_create_data_channel(pc, label, unordered, maxRetransmits); + } + + public static function deleteDataChannel(dc:Int):Void { + krom_rtc_delete_data_channel(dc); + } + + public static function sendMessage(id:Int, data:Dynamic):Int { + return krom_rtc_send_message(id, data); + } + + public static function close(id:Int):Void { + krom_rtc_close(id); + } + + public static function delete(id:Int):Void { + krom_rtc_delete(id); + } + + public static function isOpen(id:Int):Bool { + return krom_rtc_is_open(id); + } + + public static function getBufferedAmount(id:Int):Int { + return krom_rtc_get_buffered_amount(id); + } + + public static function setLocalDescriptionCallback(pc:Int, fn:String -> String -> Void):Void { + krom_rtc_set_local_description_callback(pc, fn); + } + + public static function setLocalCandidateCallback(pc:Int, fn:String -> String -> Void):Void { + krom_rtc_set_local_candidate_callback(pc, fn); + } + + public static function setStateChangeCallback(pc:Int, fn:Int -> Void):Void { + krom_rtc_set_state_change_callback(pc, fn); + } + + public static function setGatheringStateCallback(pc:Int, fn:Int -> Void):Void { + krom_rtc_set_gathering_state_callback(pc, fn); + } + + public static function setDataChannelCallback(pc:Int, fn:Int -> Void):Void { + krom_rtc_set_data_channel_callback(pc, fn); + } + + public static function setOpenCallback(id:Int, fn:Void -> Void):Void { + krom_rtc_set_open_callback(id, fn); + } + + public static function setClosedCallback(id:Int, fn:Void -> Void):Void { + krom_rtc_set_closed_callback(id, fn); + } + + public static function setErrorCallback(id:Int, fn:String -> Void):Void { + krom_rtc_set_error_callback(id, fn); + } + + public static function setMessageCallback(id:Int, fn:Dynamic -> Void):Void { + krom_rtc_set_message_callback(id, fn); + } + + public static function randomBytes(length:Int):haxe.io.Bytes { + try { + var buf = krom_rtc_random_bytes(length); + return buf != null ? haxe.io.Bytes.ofData(buf) : null; + } catch (e:Dynamic) { + return null; + } + } +} + +#end diff --git a/leenkx/Sources/leenkx/network/torrent/transport/RtcSignaling.hx b/leenkx/Sources/leenkx/network/torrent/transport/RtcSignaling.hx new file mode 100644 index 00000000..b419ad77 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/RtcSignaling.hx @@ -0,0 +1,268 @@ +package leenkx.network.torrent.transport; + +import haxe.io.Bytes; +import leenkx.network.torrent.Crypto; +import leenkx.network.torrent.TorrentInfo; + +class RtcSignaling { + + static inline var OFFER_TIMEOUT = 50 * 1000; + static inline var NUM_WANT = 5; + + public var numwant:Int = NUM_WANT; + + var client:WsTrackerClient; + var infoHashBinary:String; + var peerIdBinary:String; + var destroyed:Bool = false; + var trackerId:String = null; + + var intervalTimer:haxe.Timer = null; + var pendingOffers:Map = []; + + public var onConnected:Void -> Void; + public var onTrackerUpdate:Dynamic -> Void; + public var onCreateOffer:String -> (String -> Void) -> Void; + public var onRemoteOffer:String -> String -> Dynamic -> Void; + public var onRemoteAnswer:String -> String -> Dynamic -> Void; + public var onOfferTimeout:String -> Void; + public var onPeerLeft:String -> Void; + public var onError:String -> Void; + public var statsProvider:Void -> {downloaded:Float, uploaded:Float, + left:Float} = null; + + public function new(url:String, infoHashBinary:String, peerIdBinary:String) { + this.infoHashBinary = infoHashBinary; + this.peerIdBinary = peerIdBinary; + client = new WsTrackerClient(url); + client.onOpen = function() { + if (destroyed) return; + announce(null); + if (onConnected != null) onConnected(); + }; + client.onMessage = onMessage; + client.onClose = cleanupTimers; + client.onError = function(err) { + if (destroyed) return; + cleanupTimers(); + if (onError != null) onError(err); + }; + } + + public function start():Void { + client.start(); + } + + function onMessage(data:String):Void { + if (destroyed) return; + var msg:Dynamic; + try { + msg = haxe.Json.parse(data); + } catch(e) { + return; + } + if (msg.action == "announce") { + onAnnounceResponse(msg); + } + } + + function cleanupTimers():Void { + if (intervalTimer != null) { + intervalTimer.stop(); + intervalTimer = null; + } + for (offerId => timer in pendingOffers) { + timer.stop(); + } + pendingOffers.clear(); + } + + public function setAnnounceInterval(intervalMs:Int):Void { + if (intervalTimer != null) { + intervalTimer.stop(); + intervalTimer = null; + } + if (intervalMs > 0) { + intervalTimer = new haxe.Timer(intervalMs); + intervalTimer.run = function() { + announce(null); + }; + } + } + + public function announceEvent(event:String):Void { + announce(event); + } + + function announce(event:String):Void { + if (destroyed || !client.isConnected) return; + var stats = statsProvider != null ? statsProvider() + : {downloaded: 0.0, uploaded: 0.0, left: 0.0}; + var params:Dynamic = { + action: "announce", + info_hash: infoHashBinary, + peer_id: peerIdBinary, + numwant: numwant, + uploaded: Std.int(stats.uploaded), + downloaded: Std.int(stats.downloaded), + left: Std.int(stats.left) + }; + if (event != null) { + Reflect.setField(params, "event", event); + } + if (trackerId != null) { + Reflect.setField(params, "trackerid", trackerId); + } + if (event == "stopped" || event == "completed") { + client.send(params); + } else if (onCreateOffer != null) { + var nw = numwant; + var offers:Array = []; + var completed = 0; + var sent = false; + var announceTimer = new haxe.Timer(Std.int(OFFER_TIMEOUT)); + announceTimer.run = function() { + announceTimer.stop(); + if (!sent) { + sent = true; + if (offers.length > 0) { + Reflect.setField(params, "numwant", offers.length); + Reflect.setField(params, "offers", offers); + } + client.send(params); + } + }; + for (i in 0...nw) { + var offerIdHex = Crypto.toHex(Crypto.randomBytes(20)); + createSingleOffer(offerIdHex, function(sdp:String) { + offers.push({ + offer: {type: "offer", sdp: sdp}, + offer_id: hexToBinaryString(offerIdHex) + }); + completed++; + if (completed == nw && !sent) { + announceTimer.stop(); + sent = true; + Reflect.setField(params, "numwant", nw); + Reflect.setField(params, "offers", offers); + client.send(params); + } + }); + } + } else { + client.send(params); + } + } + + function createSingleOffer(offerIdHex:String, cb:String -> Void):Void { + onCreateOffer(offerIdHex, cb); + var timer = new haxe.Timer(Std.int(OFFER_TIMEOUT)); + timer.run = function() { + timer.stop(); + pendingOffers.remove(offerIdHex); + if (onOfferTimeout != null) onOfferTimeout(offerIdHex); + }; + pendingOffers.set(offerIdHex, timer); + } + + function onAnnounceResponse(data:Dynamic):Void { + var dataIh = Reflect.field(data, "info_hash"); + if (Std.string(dataIh) != infoHashBinary) { + return; + } + var dataPeerId = Reflect.field(data, "peer_id"); + if (dataPeerId != null && Std.string(dataPeerId) == peerIdBinary) { + return; + } + var failure = Reflect.field(data, "failure reason"); + if (failure != null) { + trace('[RtcSignaling] FAILURE: ' + Std.string(failure)); + return; + } + var warning = Reflect.field(data, "warning message"); + if (warning != null) { + trace('[RtcSignaling] WARNING: ' + Std.string(warning)); + return; + } + var interval = Reflect.field(data, "interval"); + if (interval == null) { + interval = Reflect.field(data, "min interval"); + } + if (interval != null) { + setAnnounceInterval(Std.int(Std.parseFloat(Std.string(interval)) * 1000)); + } + var tid = Reflect.field(data, "tracker id"); + if (tid != null) { + trackerId = Std.string(tid); + } + if (Reflect.field(data, "complete") != null) { + if (onTrackerUpdate != null) onTrackerUpdate(data); + } + var offer = Reflect.field(data, "offer"); + var peerId = Reflect.field(data, "peer_id"); + if (offer != null && peerId != null) { + var peerIdHex = binaryStringToHex(Std.string(peerId)); + var offerIdHex = binaryStringToHex( + Std.string(Reflect.field(data, "offer_id"))); + if (onRemoteOffer != null) { + onRemoteOffer(peerIdHex, offerIdHex, offer); + } + } + var answer = Reflect.field(data, "answer"); + if (answer != null && peerId != null) { + var offerIdHex = binaryStringToHex( + Std.string(Reflect.field(data, "offer_id"))); + var peerIdHex = binaryStringToHex(Std.string(peerId)); + var timer = pendingOffers.get(offerIdHex); + if (timer != null) { + timer.stop(); + pendingOffers.remove(offerIdHex); + } + if (onRemoteAnswer != null) { + onRemoteAnswer(peerIdHex, offerIdHex, answer); + } + } + var event = Reflect.field(data, "event"); + if (peerId != null && Std.string(event) == "stopped") { + var peerIdHex = binaryStringToHex(Std.string(peerId)); + if (onPeerLeft != null) onPeerLeft(peerIdHex); + } + } + + public function sendAnswer(toPeerIdHex:String, offerIdHex:String, sdp:String):Void { + if (destroyed || !client.isConnected) return; + var stats = statsProvider != null ? statsProvider() + : {downloaded: 0.0, uploaded: 0.0, left: 0.0}; + var params:Dynamic = { + action: "announce", + info_hash: infoHashBinary, + peer_id: peerIdBinary, + left: Std.int(stats.left), + to_peer_id: hexToBinaryString(toPeerIdHex), + answer: {type: "answer", sdp: sdp}, + offer_id: hexToBinaryString(offerIdHex) + }; + if (trackerId != null) { + Reflect.setField(params, "trackerid", trackerId); + } + client.send(params); + } + + public function close():Void { + if (destroyed) return; + if (client.isConnected) { + announce("stopped"); + } + destroyed = true; + cleanupTimers(); + client.close(); + } + + function hexToBinaryString(hex:String):String { + return TorrentInfo.toBinaryString(Bytes.ofHex(hex)); + } + + function binaryStringToHex(bin:String):String { + return Crypto.toHex(TorrentInfo.fromBinaryString(bin)); + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/transport/WebTorrent.hx b/leenkx/Sources/leenkx/network/torrent/transport/WebTorrent.hx new file mode 100644 index 00000000..c5568911 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/WebTorrent.hx @@ -0,0 +1,50 @@ +package leenkx.network.torrent.transport; + +#if (js && !kha_krom) +@:native("WebTorrent") +extern class WebTorrent { + function new(?opts:Dynamic); + function seed(input:Dynamic, ?opts:Dynamic, ?cb:Dynamic -> Void):Torrent; + function add(torrentId:Dynamic, ?opts:Dynamic, ?cb:Torrent -> Void):Torrent; + function remove(torrent:Dynamic, ?cb:Void -> Void):Void; + function destroy(?cb:Void -> Void):Void; + function on(event:String, cb:Dynamic -> Void):Void; + var torrents:Array; +} + +@:native("Torrent") +extern class Torrent { + var ready:Bool; + var wires:Array; + var files:Array; + var magnetURI:String; + var infoHash:String; + var length:Int; + var downloaded:Int; + var uploaded:Int; + var numPeers:Int; + var discovery:Dynamic; + function on(event:String, cb:Dynamic -> Void):Void; + function destroy(?cb:Void -> Void):Void; +} + +@:native("Wire") +extern class Wire { + var peerId:String; + var addr:String; + var peerExtendedHandshake:Dynamic; + var extendedHandshake:Dynamic; + function use(ext:Dynamic):Void; + function extended(name:String, message:Dynamic):Void; + function on(event:String, cb:Dynamic -> Void):Void; +} + +extern class TorrentFile { + var name:String; + var path:String; + var length:Int; + function getBuffer(cb:Dynamic -> Void):Void; + function appendTo(elem:Dynamic):Void; + function streamTo(elem:Dynamic):Void; +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/transport/WebTorrentTransport.hx b/leenkx/Sources/leenkx/network/torrent/transport/WebTorrentTransport.hx new file mode 100644 index 00000000..baf290c6 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/WebTorrentTransport.hx @@ -0,0 +1,221 @@ +package leenkx.network.torrent.transport; + +import haxe.io.Bytes; +import leenkx.network.torrent.transport.ITransport.TransportOpts; + +#if (js && !kha_krom) +import leenkx.network.torrent.transport.WebTorrent; +import js.lib.Uint8Array; + +class WebTorrentTransport implements ITransport { + public var onReady:Void -> Void; + public var onPeerConnected:String -> Int -> Void; + public var onPeerDisconnected:Int -> Void; + public var onPeerData:String -> Bytes -> Void; + public var onPeerHandshake:String -> String -> Void; + public var onTrackerUpdate:Dynamic -> Void; + public var onTrackerAnnounce:Void -> Void; + public var onError:String -> Void; + + public var wt:WebTorrent; + public var torrent:Torrent; + var torrentCreated:Bool = false; + var extName:String = "lx_channel"; + var lastWireCount:Int = 0; + var pk:String; + var ek:String; + var identifier:String; + + public function new(?existingWt:WebTorrent) { + #if js + this.wt = existingWt; + #end + } + + public function join(identifier:String, opts:TransportOpts):Void { + #if js + var trackerOpts = opts.tracker != null ? opts.tracker : {}; + if (opts.iceServers != null) { + Reflect.setField(trackerOpts, "rtcConfig", {iceServers: opts.iceServers}); + } + if (this.wt == null) { + var wtOpts:Dynamic = {tracker: trackerOpts}; + if (opts.wtOpts != null) { + wtOpts = js.lib.Object.assign(js.lib.Object.assign({}, wtOpts), opts.wtOpts); + } + this.wt = new WebTorrent(wtOpts); + } + var blob:Dynamic = createBlob(identifier); + var torrentOpts:Dynamic = { + name: identifier, + announce: opts.announce != null ? opts.announce : ["wss://ws1.leenkx.com"] + }; + if (opts.torrentOpts != null) { + torrentOpts = js.lib.Object.assign( + js.lib.Object.assign({}, torrentOpts), opts.torrentOpts); + } + this.pk = opts.pk; + this.ek = opts.ek; + this.identifier = identifier; + if (opts.torrent != null) { + this.torrent = opts.torrent; + this.torrentCreated = false; + if (this.torrent.ready) { + onTorrentReady(); + } else { + this.torrent.on("ready", (_) -> onTorrentReady()); + } + for (wire in (this.torrent.wires:Array)) { + attachWire(wire); + } + } else { + this.torrent = this.wt.seed(blob, torrentOpts, (t) -> onTorrentReady()); + this.torrentCreated = true; + } + this.torrent.on("wire", (wire) -> attachWire(wire)); + this.wt.on("error", (err:Dynamic) -> { + if (onError != null) onError(Std.string(err)); + }); + #end + } + + public function broadcast(data:Bytes):Void { + #if js + if (torrent == null) return; + var wires = torrent.wires; + for (w in 0...wires.length) { + var extHs = wires[w].peerExtendedHandshake; + if (extHs != null && extHs.m != null) { + var m:Dynamic = extHs.m; + if (Reflect.field(m, extName) != null) { + wires[w].extended(extName, haxeBytesToJs(data)); + } + } + } + #end + } + + public function sendTo(peerPk:String, data:Bytes):Bool { + #if js + if (torrent == null) return false; + var wires = torrent.wires; + for (w in 0...wires.length) { + var extHs = wires[w].peerExtendedHandshake; + if (extHs != null && extHs.m != null && extHs.pk != null + && extHs.pk.toString() == peerPk + && Reflect.field(extHs.m, extName) != null) { + wires[w].extended(extName, haxeBytesToJs(data)); + return true; + } + } + #end + return false; + } + + public function getPeerCount():Int { + #if js + if (torrent == null) return 0; + return torrent.wires.length; + #else + return 0; + #end + } + + public function destroy(?cb:Void -> Void):Void { + #if js + if (wt != null && torrentCreated) { + wt.remove(torrent, cb); + } else if (cb != null) { + cb(); + } + #else + if (cb != null) cb(); + #end + } + + #if js + function onTorrentReady():Void { + if (onReady != null) onReady(); + if (torrent.discovery != null && torrent.discovery.tracker != null) { + torrent.discovery.tracker.on("update", (update:Dynamic) -> { + if (onTrackerUpdate != null) onTrackerUpdate(update); + }); + } + if (torrent.discovery != null) { + torrent.discovery.on("trackerAnnounce", () -> { + if (onTrackerAnnounce != null) onTrackerAnnounce(); + checkWireCount(); + }); + } + } + + function attachWire(wire:Wire):Void { + wire.use(wireExtension(wire)); + wire.on("close", (_) -> { + if (onPeerDisconnected != null) + onPeerDisconnected(torrent.wires.length); + }); + } + + function wireExtension(wire:Wire):Dynamic { + var ext:Dynamic = { + name: extName, + onExtendedHandshake: (handshake:Dynamic) -> { + checkWireCount(); + if (onPeerHandshake != null) { + var hsPk:String = handshake.pk != null ? handshake.pk.toString() : ""; + var hsEk:String = handshake.ek != null ? handshake.ek.toString() : ""; + onPeerHandshake(hsPk, hsEk); + } + }, + onMessage: (msg:Dynamic) -> { + var peerPk:String = ""; + var extHs = wire.peerExtendedHandshake; + if (extHs != null && extHs.pk != null) { + peerPk = extHs.pk.toString(); + } + if (onPeerData != null) + onPeerData(peerPk, jsBytesToHaxe(msg)); + } + }; + wire.extendedHandshake.id = identifier; + wire.extendedHandshake.pk = pk; + wire.extendedHandshake.ek = ek; + return ext; + } + + function checkWireCount():Void { + var count = torrent.wires.length; + if (count != lastWireCount) { + if (count > lastWireCount) { + if (onPeerConnected != null) onPeerConnected(null, count); + } else { + if (onPeerDisconnected != null) onPeerDisconnected(count); + } + lastWireCount = count; + } + } + + function createBlob(identifier:String):Dynamic { + if (js.Syntax.typeof(js.Syntax.code("File")) != "undefined") { + return new js.html.File([identifier], identifier); + } else { + var buf:Dynamic = js.Syntax.code('Buffer.from({0})', identifier); + Reflect.setField(buf, "name", identifier); + return buf; + } + } + + static function haxeBytesToJs(bytes:Bytes):Uint8Array { + return new Uint8Array(@:privateAccess bytes.b); + } + + static function jsBytesToHaxe(arr:Dynamic):Bytes { + var len:Int = arr.length; + var bytes = Bytes.alloc(len); + for (i in 0...len) bytes.set(i, arr[i]); + return bytes; + } + #end +} +#end diff --git a/leenkx/Sources/leenkx/network/torrent/transport/WireTransport.hx b/leenkx/Sources/leenkx/network/torrent/transport/WireTransport.hx new file mode 100644 index 00000000..4c6b1aa9 --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/WireTransport.hx @@ -0,0 +1,145 @@ +package leenkx.network.torrent.transport; + +import haxe.io.Bytes; +import leenkx.network.torrent.TorrentInfo; +import leenkx.network.torrent.peer.IPeerChannel; +import leenkx.network.torrent.peer.RtcPeerPool; +import leenkx.network.torrent.peer.WireProtocol; +import leenkx.network.torrent.transport.ITransport.TransportOpts; + +class WireTransport implements ITransport { + static inline var EXT = "lx_channel"; + + public var onReady:Void -> Void; + public var onPeerConnected:String -> Int -> Void; + public var onPeerDisconnected:Int -> Void; + public var onPeerData:String -> Bytes -> Void; + public var onPeerHandshake:String -> String -> Void; + public var onTrackerUpdate:Dynamic -> Void; + public var onTrackerAnnounce:Void -> Void; + public var onError:String -> Void; + + var announceUrl:String; + var iceServers:Array; + var identifier:String; + var pk:String = ""; + var ek:String = ""; + var infoHash:Bytes; + var btPeerId:Bytes; + + var pool:RtcPeerPool = null; + var wires:Map = []; + var peerPks:Map = []; + var ready:Bool = false; + + public function new(announceUrl:String, ?iceServers:Array) { + this.announceUrl = announceUrl; + this.iceServers = iceServers; + } + + public function join(identifier:String, opts:TransportOpts):Void { + this.identifier = identifier; + var url = announceUrl; + if (opts.announce != null && opts.announce.length > 0) { + url = opts.announce[0]; + } + if (opts.iceServers != null) iceServers = opts.iceServers; + pk = opts.pk != null ? opts.pk : ""; + ek = opts.ek != null ? opts.ek : ""; + infoHash = TorrentInfo.fromBinaryString(opts.infoHashBinary); + btPeerId = TorrentInfo.fromBinaryString(opts.peerIdBinary); + + pool = new RtcPeerPool(iceServers); + pool.onPeerChannel = onPeerChannel; + pool.onPeerLeft = onPeerLeft; + pool.onReady = function() { + ready = true; + if (onTrackerAnnounce != null) onTrackerAnnounce(); + if (onReady != null) onReady(); + }; + pool.onTrackerUpdate = function(d) { + if (onTrackerUpdate != null) onTrackerUpdate(d); + }; + pool.onError = function(msg) { + if (onError != null) onError(msg); + }; + pool.connect(url, opts.infoHashBinary, opts.peerIdBinary); + } + + public function broadcast(data:Bytes):Void { + for (peerId in wires.keys()) { + var wire = wires.get(peerId); + if (wire != null) wire.sendExtended(EXT, data); + } + } + + public function sendTo(peerPk:String, data:Bytes):Bool { + for (peerId in peerPks.keys()) { + if (peerPks.get(peerId) == peerPk) { + var wire = wires.get(peerId); + if (wire != null) return wire.sendExtended(EXT, data); + return false; + } + } + return false; + } + + public function getPeerCount():Int { + return Lambda.count(wires); + } + + public function destroy(?cb:Void -> Void):Void { + if (pool != null) { + pool.close(); + pool = null; + } + wires.clear(); + peerPks.clear(); + ready = false; + if (cb != null) cb(); + } + + function onPeerChannel(peerId:String, ch:IPeerChannel):Void { + var wire = new WireProtocol(ch, infoHash, btPeerId); + wires.set(peerId, wire); + wire.extHandshakeFields = {id: identifier, pk: pk, ek: ek}; + wire.onExtendedHandshake = function(hs:Dynamic) { + var pkBytes:Bytes = Reflect.field(hs, "pk"); + var ekBytes:Bytes = Reflect.field(hs, "ek"); + var remotePk = pkBytes != null ? pkBytes.toString() : ""; + var remoteEk = ekBytes != null ? ekBytes.toString() : ""; + if (remotePk != "") peerPks.set(peerId, remotePk); + if (onPeerHandshake != null && remotePk != "") { + onPeerHandshake(remotePk, remoteEk); + } + if (onPeerConnected != null) { + onPeerConnected(peerId, getPeerCount()); + } + }; + wire.onExtended = function(name:String, data:Bytes) { + if (name != EXT) return; + var senderPk = peerPks.get(peerId); + if (onPeerData != null) { + onPeerData(senderPk != null ? senderPk : peerId, data); + } + }; + wire.onClose = function() dropWire(peerId); + wire.onError = function(msg:String) { + if (onError != null) onError(msg); + }; + wire.start([EXT]); + } + + function onPeerLeft(peerId:String):Void { + dropWire(peerId); + } + + function dropWire(peerId:String):Void { + if (!wires.exists(peerId)) return; + wires.remove(peerId); + peerPks.remove(peerId); + if (onPeerDisconnected != null) { + onPeerDisconnected(getPeerCount()); + } + } +} diff --git a/leenkx/Sources/leenkx/network/torrent/transport/WsTrackerClient.hx b/leenkx/Sources/leenkx/network/torrent/transport/WsTrackerClient.hx new file mode 100644 index 00000000..a17f910d --- /dev/null +++ b/leenkx/Sources/leenkx/network/torrent/transport/WsTrackerClient.hx @@ -0,0 +1,414 @@ +package leenkx.network.torrent.transport; + +import leenkx.network.Types.MessageType; +import leenkx.network.WebSocket; +import leenkx.network.torrent.Crypto; +import leenkx.network.torrent.TorrentClient; +import leenkx.network.torrent.TorrentInfo; + +class WsTrackerClient { + + static inline var RECONNECT_MINIMUM = 10 * 1000; + static inline var RECONNECT_MAXIMUM = 60 * 60 * 1000; + static inline var RECONNECT_VARIANCE = 5 * 60 * 1000; + + public var onOpen:Void -> Void; + public var onMessage:String -> Void; + public var onClose:Void -> Void; + public var onError:String -> Void; + + public var isConnected(get, never):Bool; + function get_isConnected():Bool return connected; + + var url:String; + var ws:WebSocket = null; + var connected:Bool = false; + var destroyed:Bool = false; + var reconnecting:Bool = false; + var retries:Int = 0; + var reconnectTimer:haxe.Timer = null; + + public function new(url:String) { + this.url = url; + } + + public function start():Void { + openSocket(); + } + + function openSocket():Void { + destroyed = false; + var sock:WebSocket = null; + try { + sock = new WebSocket(url); + } catch(e:Dynamic) { + TorrentClient.postMain(function() onSocketError(e)); + return; + } + ws = sock; + sock.onopen = function() { + TorrentClient.postMain(onSocketConnect); + }; + sock.onmessage = function(m:MessageType) { + switch (m) { + case StrMessage(s): + var msg = s; + TorrentClient.postMain(function() onSocketData(msg)); + case BytesMessage(b): + var msg = b.readAllAvailableBytes().toString(); + TorrentClient.postMain(function() onSocketData(msg)); + } + }; + sock.onclose = function() { + TorrentClient.postMain(onSocketClose); + }; + sock.onerror = function(e:Dynamic) { + var err = Std.string(e); + TorrentClient.postMain(function() onSocketError(err)); + }; + } + + function onSocketConnect():Void { + if (destroyed) return; + connected = true; + if (reconnecting) { + reconnecting = false; + retries = 0; + } + if (onOpen != null) onOpen(); + } + + function onSocketData(msg:String):Void { + if (destroyed) return; + if (onMessage != null) onMessage(msg); + } + + function onSocketClose():Void { + if (destroyed) return; + connected = false; + if (onClose != null) onClose(); + startReconnectTimer(); + } + + function onSocketError(err:String):Void { + if (destroyed) return; + connected = false; + if (onError != null) onError(err); + startReconnectTimer(); + } + + function startReconnectTimer():Void { + if (reconnecting || destroyed) return; + reconnecting = true; + var ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + + Math.min(Math.pow(2, retries) * RECONNECT_MINIMUM, + RECONNECT_MAXIMUM); + if (reconnectTimer != null) reconnectTimer.stop(); + reconnectTimer = new haxe.Timer(Std.int(ms)); + reconnectTimer.run = function() { + retries++; + reconnectTimer.stop(); + reconnectTimer = null; + reconnecting = false; + openSocket(); + }; + } + + public function send(msg:Dynamic):Bool { + if (ws == null || !connected) return false; + var json = haxe.Json.stringify(msg); + var out = new StringBuf(); + for (i in 0...json.length) { + var c = StringTools.fastCodeAt(json, i); + if (c > 0x7F || c < 0x20) { + out.add("\\u"); + out.add(StringTools.hex(c, 4)); + } else { + out.addChar(c); + } + } + json = out.toString(); + ws.send(json); + return true; + } + + public function close():Void { + destroyed = true; + connected = false; + if (reconnectTimer != null) { + reconnectTimer.stop(); + reconnectTimer = null; + } + if (ws != null) { + ws.close(); + ws = null; + } + } +} + +#if (sys || kha_krom) +class WsTrackerHandler extends leenkx.network.WebSocketHandler { + public function new(socket:leenkx.network.SocketImpl) { + super(socket); + } +} + +typedef WsSwarmPeer = { + h:WsTrackerHandler, + peerId:String, + infoHash:String, + left:Float +} + +class WsTrackerServer { + public var intervalSec:Int = 30; + public var onLog:String -> Void = null; + public var onError:String -> Void = null; + + var host:String; + var port:Int; + var maxConns:Int; + var server:leenkx.network.WebSocketServer + = null; + + var peers:Map = []; + var connKeys:Map> = []; + var rrCursor:Map = []; + + public function new(host:String, port:Int, maxConns:Int = 128) { + this.host = host; + this.port = port; + this.maxConns = maxConns; + } + + function createServer():leenkx.network.WebSocketServer { + return new leenkx.network.WebSocketServer + (host, port, maxConns); + } + + public function start():Void { + if (server != null) return; + server = createServer(); + var self = this; + server.onClientAdded = function(h:WsTrackerHandler) { + h.onmessage = function(m:MessageType) { + self.onMsg(h, m); + }; + h.onclose = function() self.onConnClose(h); + h.onerror = function(e:Dynamic) { + self.err("client " + h.id.substr(0, 8) + + ": " + Std.string(e)); + }; + self.log("client " + h.id.substr(0, 8) + + " connected"); + }; + server.onClientRemoved = function(h:WsTrackerHandler) { + self.onConnClose(h); + }; + server.start(); + log("ws tracker listening on " + url() + + " maxConns=" + maxConns); + } + + public function url():String { + return scheme() + "://" + host + ":" + port; + } + + function scheme():String return "ws"; + + public function stop():Void { + if (server != null) server.stop(); + server = null; + peers.clear(); + connKeys.clear(); + rrCursor.clear(); + } + + function onMsg(h:WsTrackerHandler, m:MessageType):Void { + var s:String = switch (m) { + case StrMessage(t): t; + case BytesMessage(b): + b.readAllAvailableBytes().toString(); + }; + var msg:Dynamic = null; + try { + msg = haxe.Json.parse(s); + } catch (e:Dynamic) { + return; + } + if (msg == null || msg.action != "announce") return; + var ih = Std.string(Reflect.field(msg, "info_hash")); + var pid = Std.string(Reflect.field(msg, "peer_id")); + if (ih == null || ih == "null" || pid == null + || pid == "null") { + return; + } + var key = pid + "|" + ih; + var left = Std.parseFloat( + Std.string(Reflect.field(msg, "left"))); + if (Math.isNaN(left)) left = 0; + if (!peers.exists(key)) { + var arr = connKeys.get(h); + if (arr == null) connKeys.set(h, arr = []); + arr.push(key); + } + peers.set(key, {h: h, peerId: pid, infoHash: ih, + left: left}); + + var ev:Dynamic = Reflect.field(msg, "event"); + + var offers:Array = Reflect.field(msg, "offers"); + if (offers != null) { + for (o in offers) { + var t = pickPeer(ih, key); + if (t == null) break; + send(t.h, { + action: "announce", + info_hash: ih, + offer: Reflect.field(o, "offer"), + offer_id: Reflect.field(o, "offer_id"), + peer_id: pid + }); + log("offer " + shortId(pid) + " -> " + + shortId(t.peerId) + " ih=" + + Crypto.toHex(TorrentInfo.fromBinaryString(ih)) + .substr(0, 8)); + } + } + + var answer:Dynamic = Reflect.field(msg, "answer"); + var toPid:Dynamic = Reflect.field(msg, "to_peer_id"); + if (answer != null && toPid != null) { + var t = peers.get(Std.string(toPid) + "|" + ih); + if (t != null) { + send(t.h, { + action: "announce", + info_hash: ih, + peer_id: pid, + answer: answer, + offer_id: Reflect.field(msg, "offer_id") + }); + log("answer " + shortId(pid) + " -> " + + shortId(t.peerId)); + } + } + + var counts = swarmCounts(ih); + send(h, { + action: "announce", + info_hash: ih, + interval: intervalSec, + "tracker id": "leenkx-local-ws", + complete: counts.complete, + incomplete: counts.incomplete + }); + log("announce peer=" + shortId(pid) + " ih=" + + Crypto.toHex(TorrentInfo.fromBinaryString(ih)) + .substr(0, 8) + + (ev != null ? " event=" + Std.string(ev) : "") + + " swarm=" + counts.complete + "s/" + + counts.incomplete + "l"); + + if (Std.string(ev) == "stopped") { + removePeer(key); + } + } + + function pickPeer(ih:String, selfKey:String):WsSwarmPeer { + var list = [for (p in peers) + if (p.infoHash == ih + && (p.peerId + "|" + ih) != selfKey) p]; + if (list.length == 0) return null; + var cur = rrCursor.exists(ih) ? rrCursor.get(ih) : 0; + var p = list[cur % list.length]; + rrCursor.set(ih, cur + 1); + return p; + } + + function swarmCounts(ih:String):{complete:Int, incomplete:Int} { + var c = 0; + var i = 0; + for (p in peers) { + if (p.infoHash != ih) continue; + if (p.left <= 0) c++; else i++; + } + return {complete: c, incomplete: i}; + } + + function removePeer(key:String):Void { + var p = peers.get(key); + if (p == null) return; + peers.remove(key); + for (q in peers) { + if (q.infoHash != p.infoHash) continue; + send(q.h, { + action: "announce", + info_hash: p.infoHash, + peer_id: p.peerId, + event: "stopped" + }); + } + } + + function onConnClose(h:WsTrackerHandler):Void { + var keys = connKeys.get(h); + if (keys != null) { + connKeys.remove(h); + for (k in keys) { + var p = peers.get(k); + if (p != null && p.h == h) removePeer(k); + } + } + } + + function send(h:WsTrackerHandler, msg:Dynamic):Void { + try { + h.send(haxe.Json.stringify(msg)); + } catch (e:Dynamic) { + err("send: " + Std.string(e)); + } + } + + static function shortId(bin:String):String { + return Crypto.toHex(TorrentInfo.fromBinaryString(bin)) + .substr(-8); + } + + function log(m:String):Void { + if (onLog != null) { + var f = onLog; + TorrentClient.postMain(function() f(m)); + } + } + + function err(m:String):Void { + if (onError != null) { + var f = onError; + TorrentClient.postMain(function() f(m)); + } + } +} +#end + +#if sys +class WssTrackerServer extends WsTrackerServer { + var certPem:String; + var keyPem:String; + + public function new(host:String, port:Int, certPem:String, keyPem:String, maxConns:Int = 128) { + super(host, port, maxConns); + this.certPem = certPem; + this.keyPem = keyPem; + } + + override function scheme():String return "wss"; + + override function createServer():leenkx.network.WebSocketServer { + var cert = sys.ssl.Certificate.loadFile(certPem); + var key = sys.ssl.Key.loadFile(keyPem); + return new leenkx.network.WebSocketSecureServer + (host, port, cert, key, cert, + maxConns); + } +} +#end