snowflake/proxy/proxypair.coffee
David Fifield ab34f8e889 Use chunk.byteLength as appropriate for ArrayBuffers.
Without this, running with non-dummy rate limiter (e.g. ?ratelimit=1000)
would try to add undefined to a number resulting in NaN.
2018-12-19 21:30:39 -07:00

196 lines
6.1 KiB
CoffeeScript

###
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
MAX_BUFFER: 10 * 1024 * 1024
pc: null
c2rSchedule: []
r2cSchedule: []
client: null # WebRTC Data channel
relay: null # websocket
timer: 0
running: true
active: false # Whether serving a client.
flush_timeout_id: null
onCleanup: null
id: null
###
Constructs a ProxyPair where:
- @relayAddr is the destination relay
- @rateLimit specifies a rate limit on traffic
###
constructor: (@relayAddr, @rateLimit) ->
@active = false
@id = genSnowflakeID()
# Prepare a WebRTC PeerConnection and await for an SDP offer.
begin: ->
@pc = new PeerConnection config, {
optional: [
{ DtlsSrtpKeyAgreement: true }
{ RtpDataChannels: false }
] }
@pc.onicecandidate = (evt) =>
# Browser sends a null candidate once the ICE gathering completes.
# In this case, it makes sense to send one copy-paste blob.
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.'
if COPY_PASTE_ENABLED
Signalling.send @pc.localDescription
else
snowflake.broker.sendAnswer @id, @pc.localDescription
# OnDataChannel triggered remotely from the client when connection succeeds.
@pc.ondatachannel = (dc) =>
channel = dc.channel
dbg 'Data Channel established...'
@prepareDataChannel channel
@client = channel
receiveWebRTCOffer: (offer) ->
if 'offer' != offer.type
log 'Invalid SDP received -- was not an offer.'
return false
try
err = @pc.setRemoteDescription offer
catch e
log 'Invalid SDP message.'
return false
dbg 'SDP ' + offer.type + ' successfully received.'
@active = true
true
# Given a WebRTC DataChannel, prepare callbacks.
prepareDataChannel: (channel) =>
channel.onopen = =>
log 'WebRTC DataChannel opened!'
snowflake.state = 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.
@connectRelay()
channel.onclose = =>
log 'WebRTC DataChannel closed.'
snowflake.ui?.setStatus 'disconnected by webrtc.'
snowflake.ui?.setActive false
snowflake.state = MODE.INIT
@flush()
@close()
# TODO: Change this for multiplexing.
snowflake.reset()
channel.onerror = -> log 'Data channel error!'
channel.binaryType = "arraybuffer"
channel.onmessage = @onClientToRelayMessage
# Assumes WebRTC datachannel is connected.
connectRelay: =>
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(@pc.remoteDescription?.sdp)
params = []
if peer_ip?
params.push(["client_ip", peer_ip])
@relay = makeWebsocket @relayAddr, params
@relay.label = 'websocket-relay'
@relay.onopen = =>
if @timer
clearTimeout @timer
@timer = 0
log @relay.label + ' connected!'
snowflake.ui?.setStatus 'connected'
@relay.onclose = =>
log @relay.label + ' closed.'
snowflake.ui?.setStatus 'disconnected.'
snowflake.ui?.setActive false
snowflake.state = MODE.INIT
@flush()
@close()
@relay.onerror = @onError
@relay.onmessage = @onRelayToClientMessage
# TODO: Better websocket timeout handling.
@timer = setTimeout((=>
return if 0 == @timer
log @relay.label + ' timed out connecting.'
@relay.onclose()), 5000)
# WebRTC --> websocket
onClientToRelayMessage: (msg) =>
if DEBUG
log 'WebRTC --> websocket data: ' + msg.data.byteLength + ' bytes'
@c2rSchedule.push msg.data
@flush()
# websocket --> WebRTC
onRelayToClientMessage: (event) =>
if DEBUG
log 'websocket --> WebRTC data: ' + event.data.byteLength + ' bytes'
@r2cSchedule.push event.data
@flush()
onError: (event) =>
ws = event.target
log ws.label + ' error.'
@close()
# Close both WebRTC and websocket.
close: ->
if @timer
clearTimeout @timer
@timer = 0
@running = false
@client.close() if @webrtcIsReady()
@relay.close() if @relayIsReady()
relay = null
# Send as much data in both directions as the rate limit currently allows.
flush: =>
clearTimeout @flush_timeout_id if @flush_timeout_id
@flush_timeout_id = null
busy = true
checkChunks = =>
busy = false
# WebRTC --> websocket
if @relayIsReady() &&
@relay.bufferedAmount < @MAX_BUFFER &&
@c2rSchedule.length > 0
chunk = @c2rSchedule.shift()
@rateLimit.update chunk.byteLength
@relay.send chunk
busy = true
# websocket --> WebRTC
if @webrtcIsReady() &&
@client.bufferedAmount < @MAX_BUFFER &&
@r2cSchedule.length > 0
chunk = @r2cSchedule.shift()
@rateLimit.update chunk.byteLength
@client.send chunk
busy = true
checkChunks() while busy && !@rateLimit.isLimited()
if @r2cSchedule.length > 0 || @c2rSchedule.length > 0 ||
(@relayIsReady() && @relay.bufferedAmount > 0) ||
(@webrtcIsReady() && @client.bufferedAmount > 0)
@flush_timeout_id = setTimeout @flush, @rateLimit.when() * 1000
webrtcIsReady: -> null != @client && 'open' == @client.readyState
relayIsReady: -> (null != @relay) && (WebSocket.OPEN == @relay.readyState)
isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState