/* * Copyright (c) 2013 Antoine Martin * * Licensed under MPL 2.0 * * xpra wire protocol for WebTransport * */ /* The main Xpra wire protocol */ class XpraWebTransportProtocol { constructor() { this.verify_connected_timer = 0; this.packet_handler = null; this.webtransport = null; this.stream = null; this.writer = null; this.raw_packets = []; this.rQ = []; // Receive queue this.sQ = []; // Send queue this.header = []; //Queue processing via intervals this.process_interval = 0; //milliseconds } cancel_connected_timer() { if (this.verify_connected_timer) { clearTimeout(this.verify_connected_timer); this.verify_connected_timer = 0; } } async open(uri) { const me = this; // (re-)init this.raw_packets = []; this.rQ = []; this.sQ = []; this.header = []; this.webtransport = null; this.stream = null; function handle(packet) { me.packet_handler(packet); } this.verify_connected_timer = setTimeout( () => handle(["error", "connection timed out", 0]), CONNECT_TIMEOUT ); // connect the socket try { // console.log("opening WebTransport connection to " + uri); this.webtransport = new WebTransport(uri); } catch (error) { handle(["error", `${error}`, 0]); return; } try { // console.log("waiting for connection"); await this.webtransport.ready; this.cancel_connected_timer(); } catch (e) { console.error("connection failed: " + e); handle(["error", e.toString()]); this.cancel_connected_timer(); return; } this.webtransport.closed.then(() => { handle(["close", "transport closed"]) }) .catch((e) => { console.error("error closing WebTransport: " + e); handle(["close", "error", e.toString()]) }); // console.log("creating stream"); this.stream = await this.webtransport.createBidirectionalStream(); // console.log("starting read loop with stream=" + this.stream); this.read_loop().then(() => { // console.log("read loop ended, closing"); handle(["close", "read loop ended"]) }) .catch((e) => { console.error("error in read loop: " + e); handle(["close", "read loop error", e.toString()]) }); this.writer = this.stream.writable.getWriter(); handle(["open"]); // console.log("async open end"); } async read_loop() { const reader = this.stream.readable.getReader(); const me = this; while (true) { const { value, done } = await reader.read(); if (done) { break; } this.rQ.push(value); setTimeout(() => me.process_receive_queue(), this.process_interval); } } protocol_error(message) { this.packet_handler(["error", message]); console.error("protocol error: " + message); this.close(); } close() { this.cancel_connected_timer(); const wt = this.webtransport; if (wt) { wt.closed.then(() => { // console.log("closed WebTransport connection"); handle(["close", "WebTransport closed"]) }) .catch((e) => { // console.log("error closing WebTransport connection: " + e); handle(["close", "error closing WebTransport connection", e.toString()]) }); } this.webtransport = null; } process_receive_queue() { while (this.webtransport && this.do_process_receive_queue()); } do_process_receive_queue() { if (this.header.length < 8 && this.rQ.length > 0) { //add from receive queue data to header until we get the 8 bytes we need: while (this.header.length < 8 && this.rQ.length > 0) { const slice = this.rQ[0]; const needed = 8 - this.header.length; const n = Math.min(needed, slice.length); this.header.push(...slice.subarray(0, n)); if (slice.length > needed) { //replace the slice with what is left over: this.rQ[0] = slice.subarray(n); } else { //this slice has been fully consumed already: this.rQ.shift(); } } //verify the header format: if (this.header[0] !== 80) { let message = `invalid packet header format: ${this.header[0]}`; if (this.header.length > 1) { let hex = ""; for (let p of this.header) { const v = p.toString(16); hex += v.length < 2 ? `0${v}` : v; } message += `: 0x${hex}`; } this.protocol_error(message); return false; } } if (this.header.length < 8) { //we need more data to continue return false; } let proto_flags = this.header[1]; const proto_crypto = proto_flags & 0x2; if (proto_crypto) { throw "crypto packets not supported"; } if (proto_flags & 0x8) { //this flag is unused client-side, so just ignore it: proto_flags = proto_flags & ~0x8; } if (proto_flags > 1 && proto_flags !== 0x10) { this.protocol_error(`we can't handle this protocol flag yet: ${proto_flags}`); return; } const level = this.header[2]; if (level & 0x20) { this.protocol_error("lzo compression is not supported"); return false; } const index = this.header[3]; if (index >= 20) { this.protocol_error(`invalid packet index: ${index}`); return false; } let packet_size = [4, 5, 6, 7].reduce( (accumulator, value) => accumulator * 0x1_00 + this.header[value], 0 ); // verify that we have enough data for the full payload: let rsize = this.rQ.reduce( (accumulator, value) => accumulator + value.length, 0 ); if (rsize < packet_size) { return false; } // done parsing the header, the next packet will need a new one: this.header = []; let packet_data; if (this.rQ[0].length === packet_size) { //exact match: the payload is in a buffer already: packet_data = this.rQ.shift(); } else { //aggregate all the buffers into "packet_data" until we get exactly "packet_size" bytes: packet_data = new Uint8Array(packet_size); rsize = 0; while (rsize < packet_size) { const slice = this.rQ[0]; const needed = packet_size - rsize; if (slice.length > needed) { //add part of this slice: packet_data.set(slice.subarray(0, needed), rsize); rsize += needed; this.rQ[0] = slice.subarray(needed); } else { //add this slice in full: packet_data.set(slice, rsize); rsize += slice.length; this.rQ.shift(); } } } //decompress it if needed: if (level !== 0) { let inflated; if (level & 0x10) { inflated = lz4.decode(packet_data); } else if (level & 0x40) { inflated = new Uint8Array(BrotliDecode(packet_data)); } else { throw "zlib is no longer supported"; } packet_data = inflated; } //save it for later? (partial raw packet) if (index > 0) { this.raw_packets[index] = packet_data; if (this.raw_packets.length >= 4) { this.protocol_error(`too many raw packets: ${this.raw_packets.length}`); return false; } } else { //decode raw packet string into objects: let packet = null; try { if (proto_flags === 0x10) { packet = rdecode(packet_data); } else if (proto_flags === 0x1) { throw `rencode legacy mode is not supported, protocol flag: ${proto_flags}`; } else { throw `invalid packet encoder flags ${proto_flags}`; } for (const index in this.raw_packets) { packet[index] = this.raw_packets[index]; } this.raw_packets = {}; } catch (error) { //FIXME: maybe we should error out and disconnect here? console.error("error decoding packet", error); console.error(`packet=${packet_data}`); console.error(`protocol flags=${proto_flags}`); this.raw_packets = []; return this.rQ.length > 0; } try { // pass to our packet handler this.packet_handler(packet); } catch (error) { //FIXME: maybe we should error out and disconnect here? console.error(`error processing packet ${packet[0]}: ${error}`); console.error(` packet data: ${packet_data}`) } } return this.rQ.length > 0; } process_send_queue() { while (this.sQ.length > 0 && this.webtransport) { const packet = this.sQ.shift(); if (!packet) { return; } let proto_flags = 0x10; let bdata = null; try { bdata = rencode(packet); } catch (error) { console.error("Error: failed to encode packet:", packet); console.error(error); continue; } const payload_size = bdata.length; const packet_data = new Uint8Array(payload_size + 8); const level = 0; //header: packet_data[0] = "P".charCodeAt(0); packet_data[1] = proto_flags; packet_data[2] = level; packet_data[3] = 0; //size header: for (let index = 0; index < 4; index++) { packet_data[7 - index] = (payload_size >> (8 * index)) & 0xff; } packet_data.set(bdata, 8); if (this.stream) { this.writer.write(new Uint8Array(packet_data).buffer); } } } send(packet) { this.sQ[this.sQ.length] = packet; setTimeout(() => this.process_send_queue(), this.process_interval); } set_packet_handler(callback) { this.packet_handler = callback; } set_cipher_in(caps, key) { throw "not supported with WebTransport"; } set_cipher_out(caps, key) { throw "not supported with WebTransport"; } }