578 lines
16 KiB
Haxe
578 lines
16 KiB
Haxe
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 {
|
|
|
|
public static var PEERTIMEOUT:Float = 5 * 60 * 1000;
|
|
public static var defaultHeartbeat:Int = 30000;
|
|
|
|
public var identifier:String;
|
|
public var seed:String;
|
|
public var pk:String;
|
|
public var ek:String;
|
|
public var timeout:Float;
|
|
public var announce:Array<String>;
|
|
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<String, PeerInfo> = [];
|
|
public var seen:Map<String, Float> = [];
|
|
public var seenFired:Map<String, Bool> = [];
|
|
public var pkToAddress:Map<String, String> = [];
|
|
public var isRelay:Bool = false;
|
|
public var lastPeerCount:Null<Int> = null;
|
|
public var lastPeerId:String = null;
|
|
|
|
public var serverAddress:String = null;
|
|
public var heartbeatTimer:Null<haxe.Timer> = 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 var torrents(get, never):Map<String, Torrent>;
|
|
public var onTorrentAdded:Torrent -> Void;
|
|
public var onTorrentDone:Torrent -> Void;
|
|
var swarm:TorrentClient = null;
|
|
|
|
function get_torrents():Map<String, Torrent> {
|
|
return swarm != null ? swarm.torrents : [];
|
|
}
|
|
|
|
function getSwarm():TorrentClient {
|
|
if (swarm == null) {
|
|
swarm = new TorrentClient();
|
|
swarm.onError = function(e) {
|
|
if (onError != null) onError(e);
|
|
};
|
|
swarm.onTorrentAdded = function(t) {
|
|
if (onTorrentAdded != null) onTorrentAdded(t);
|
|
};
|
|
swarm.onTorrentDone = function(t) {
|
|
if (onTorrentDone != null) onTorrentDone(t);
|
|
};
|
|
}
|
|
return swarm;
|
|
}
|
|
|
|
public function add(torrentId:Dynamic, ?opts:Dynamic, ?cb:Torrent -> Void):Torrent {
|
|
return getSwarm().add(torrentId, opts, cb);
|
|
}
|
|
|
|
public function seedMeta(meta:MetaInfo,
|
|
files:Array<{path:String, data:Bytes}>,
|
|
?opts:Dynamic, ?cb:Torrent -> Void):Torrent {
|
|
return getSwarm().seedMeta(meta, files, opts, cb);
|
|
}
|
|
|
|
public function get(id:Dynamic):Torrent {
|
|
if (Std.isOfType(id, Torrent)) return id;
|
|
return swarm != null ? swarm.get(id) : null;
|
|
}
|
|
|
|
public function remove(infoHashHex:String, ?deleteData:Bool):Void {
|
|
if (swarm != null) swarm.remove(infoHashHex, deleteData);
|
|
}
|
|
|
|
public function listen(port:Int):Void {
|
|
getSwarm().listen(port);
|
|
}
|
|
|
|
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 : TorrentClient.defaultTrackers;
|
|
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 : defaultHeartbeat;
|
|
}
|
|
|
|
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 (swarm != null) {
|
|
swarm.destroy();
|
|
swarm = null;
|
|
}
|
|
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 = (message.length > 0 && message.get(0) == 0x00)
|
|
? null : Crypto.hash(message).sub(0, 16).toHex();
|
|
var t = now();
|
|
if (hash == null || !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);
|
|
if (isRelayed) 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();
|
|
}
|
|
}
|