mirror of
https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake.git
synced 2025-10-13 20:11:19 -04:00
Some files were omitted in the .eslintignore, left as an exercise to the reader. We probably want to reduce amount of globals overall and use proper es modules.
256 lines
7.7 KiB
JavaScript
256 lines
7.7 KiB
JavaScript
/* global snowflake, log, dbg, Util, PeerConnection, Snowflake, Parse, WS */
|
|
|
|
/*
|
|
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) {
|
|
if ('offer' !== offer.type) {
|
|
log('Invalid SDP received -- was not an offer.');
|
|
return false;
|
|
}
|
|
try {
|
|
this.pc.setRemoteDescription(offer);
|
|
} catch (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]);
|
|
}
|
|
var relay = 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(relay.label + ' connected!');
|
|
return snowflake.ui.setStatus('connected');
|
|
};
|
|
this.relay.onclose = () => {
|
|
log(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(relay.label + ' timed out connecting.');
|
|
return 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() {
|
|
if (this.timer) {
|
|
clearTimeout(this.timer);
|
|
this.timer = 0;
|
|
}
|
|
this.running = false;
|
|
if (this.webrtcIsReady()) {
|
|
this.client.close();
|
|
}
|
|
this.client = null;
|
|
if (this.relayIsReady()) {
|
|
this.relay.close();
|
|
}
|
|
this.relay = null;
|
|
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;
|