Files
LNXSDK/leenkx/Sources/leenkx/network/torrent/LeenkxClient.hx
2026-10-27 23:06:31 -07:00

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();
}
}