snowflake/proxy/proxypair.js
2019-07-10 10:49:53 +02:00

256 lines
7.6 KiB
JavaScript

/*
Represents a single:
client <-- webrtc --> snowflake <-- websocket --> relay
Every ProxyPair has a Snowflake ID, which is necessary when responding to the
Broker with an WebRTC answer.
*/
class ProxyPair {
/*
Constructs a ProxyPair where:
- @relayAddr is the destination relay
- @rateLimit specifies a rate limit on traffic
*/
constructor(relayAddr, rateLimit, pcConfig) {
// Given a WebRTC DataChannel, prepare callbacks.
this.prepareDataChannel = this.prepareDataChannel.bind(this);
// Assumes WebRTC datachannel is connected.
this.connectRelay = this.connectRelay.bind(this);
// WebRTC --> websocket
this.onClientToRelayMessage = this.onClientToRelayMessage.bind(this);
// websocket --> WebRTC
this.onRelayToClientMessage = this.onRelayToClientMessage.bind(this);
this.onError = this.onError.bind(this);
// Send as much data in both directions as the rate limit currently allows.
this.flush = this.flush.bind(this);
this.relayAddr = relayAddr;
this.rateLimit = rateLimit;
this.pcConfig = pcConfig;
this.id = Util.genSnowflakeID();
this.c2rSchedule = [];
this.r2cSchedule = [];
}
// Prepare a WebRTC PeerConnection and await for an SDP offer.
begin() {
this.pc = new PeerConnection(this.pcConfig, {
optional: [
{
DtlsSrtpKeyAgreement: true
},
{
RtpDataChannels: false
}
]
});
this.pc.onicecandidate = (evt) => {
// Browser sends a null candidate once the ICE gathering completes.
if (null === evt.candidate) {
// TODO: Use a promise.all to tell Snowflake about all offers at once,
// once multiple proxypairs are supported.
dbg('Finished gathering ICE candidates.');
return snowflake.broker.sendAnswer(this.id, this.pc.localDescription);
}
};
// OnDataChannel triggered remotely from the client when connection succeeds.
return this.pc.ondatachannel = (dc) => {
var channel;
channel = dc.channel;
dbg('Data Channel established...');
this.prepareDataChannel(channel);
return this.client = channel;
};
}
receiveWebRTCOffer(offer) {
var e, err;
if ('offer' !== offer.type) {
log('Invalid SDP received -- was not an offer.');
return false;
}
try {
err = this.pc.setRemoteDescription(offer);
} catch (error) {
e = error;
log('Invalid SDP message.');
return false;
}
dbg('SDP ' + offer.type + ' successfully received.');
return true;
}
prepareDataChannel(channel) {
channel.onopen = () => {
log('WebRTC DataChannel opened!');
snowflake.state = Snowflake.MODE.WEBRTC_READY;
snowflake.ui.setActive(true);
// This is the point when the WebRTC datachannel is done, so the next step
// is to establish websocket to the server.
return this.connectRelay();
};
channel.onclose = () => {
log('WebRTC DataChannel closed.');
snowflake.ui.setStatus('disconnected by webrtc.');
snowflake.ui.setActive(false);
snowflake.state = Snowflake.MODE.INIT;
this.flush();
return this.close();
};
channel.onerror = function() {
return log('Data channel error!');
};
channel.binaryType = "arraybuffer";
return channel.onmessage = this.onClientToRelayMessage;
}
connectRelay() {
var params, peer_ip, ref;
dbg('Connecting to relay...');
// Get a remote IP address from the PeerConnection, if possible. Add it to
// the WebSocket URL's query string if available.
// MDN marks remoteDescription as "experimental". However the other two
// options, currentRemoteDescription and pendingRemoteDescription, which
// are not marked experimental, were undefined when I tried them in Firefox
// 52.2.0.
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/remoteDescription
peer_ip = Parse.ipFromSDP((ref = this.pc.remoteDescription) != null ? ref.sdp : void 0);
params = [];
if (peer_ip != null) {
params.push(["client_ip", peer_ip]);
}
this.relay = WS.makeWebsocket(this.relayAddr, params);
this.relay.label = 'websocket-relay';
this.relay.onopen = () => {
if (this.timer) {
clearTimeout(this.timer);
this.timer = 0;
}
log(this.relay.label + ' connected!');
return snowflake.ui.setStatus('connected');
};
this.relay.onclose = () => {
log(this.relay.label + ' closed.');
snowflake.ui.setStatus('disconnected.');
snowflake.ui.setActive(false);
snowflake.state = Snowflake.MODE.INIT;
this.flush();
return this.close();
};
this.relay.onerror = this.onError;
this.relay.onmessage = this.onRelayToClientMessage;
// TODO: Better websocket timeout handling.
return this.timer = setTimeout((() => {
if (0 === this.timer) {
return;
}
log(this.relay.label + ' timed out connecting.');
return this.relay.onclose();
}), 5000);
}
onClientToRelayMessage(msg) {
dbg('WebRTC --> websocket data: ' + msg.data.byteLength + ' bytes');
this.c2rSchedule.push(msg.data);
return this.flush();
}
onRelayToClientMessage(event) {
dbg('websocket --> WebRTC data: ' + event.data.byteLength + ' bytes');
this.r2cSchedule.push(event.data);
return this.flush();
}
onError(event) {
var ws;
ws = event.target;
log(ws.label + ' error.');
return this.close();
}
// Close both WebRTC and websocket.
close() {
var relay;
if (this.timer) {
clearTimeout(this.timer);
this.timer = 0;
}
this.running = false;
if (this.webrtcIsReady()) {
this.client.close();
}
if (this.relayIsReady()) {
this.relay.close();
}
relay = null;
return this.onCleanup();
}
flush() {
var busy, checkChunks;
if (this.flush_timeout_id) {
clearTimeout(this.flush_timeout_id);
}
this.flush_timeout_id = null;
busy = true;
checkChunks = () => {
var chunk;
busy = false;
// WebRTC --> websocket
if (this.relayIsReady() && this.relay.bufferedAmount < this.MAX_BUFFER && this.c2rSchedule.length > 0) {
chunk = this.c2rSchedule.shift();
this.rateLimit.update(chunk.byteLength);
this.relay.send(chunk);
busy = true;
}
// websocket --> WebRTC
if (this.webrtcIsReady() && this.client.bufferedAmount < this.MAX_BUFFER && this.r2cSchedule.length > 0) {
chunk = this.r2cSchedule.shift();
this.rateLimit.update(chunk.byteLength);
this.client.send(chunk);
return busy = true;
}
};
while (busy && !this.rateLimit.isLimited()) {
checkChunks();
}
if (this.r2cSchedule.length > 0 || this.c2rSchedule.length > 0 || (this.relayIsReady() && this.relay.bufferedAmount > 0) || (this.webrtcIsReady() && this.client.bufferedAmount > 0)) {
return this.flush_timeout_id = setTimeout(this.flush, this.rateLimit.when() * 1000);
}
}
webrtcIsReady() {
return null !== this.client && 'open' === this.client.readyState;
}
relayIsReady() {
return (null !== this.relay) && (WebSocket.OPEN === this.relay.readyState);
}
isClosed(ws) {
return void 0 === ws || WebSocket.CLOSED === ws.readyState;
}
};
ProxyPair.prototype.MAX_BUFFER = 10 * 1024 * 1024;
ProxyPair.prototype.pc = null;
ProxyPair.prototype.client = null; // WebRTC Data channel
ProxyPair.prototype.relay = null; // websocket
ProxyPair.prototype.timer = 0;
ProxyPair.prototype.running = true;
ProxyPair.prototype.active = false; // Whether serving a client.
ProxyPair.prototype.flush_timeout_id = null;
ProxyPair.prototype.onCleanup = null;
ProxyPair.prototype.id = null;