diff --git a/proxy/snowflake.coffee b/proxy/snowflake.coffee index 6a5e014..31051c5 100644 --- a/proxy/snowflake.coffee +++ b/proxy/snowflake.coffee @@ -10,13 +10,15 @@ this must always act as the answerer. TODO(keroserene): Complete the websocket + webrtc ProxyPair ### +DEFAULT_WEBSOCKET = '92.81.135.242:9901' + if 'undefined' != typeof module && 'undefined' != typeof module.exports console.log 'not in browser.' else window.PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection - window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate; + window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate window.RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription @@ -25,7 +27,7 @@ Query = Parse a URL query string or application/x-www-form-urlencoded body. The return type is an object mapping string keys to string values. By design, this function doesn't support multiple values for the same named parameter, - for example "a=1&a=2&a=3"; the first definition always wins. Returns null on + for example 'a=1&a=2&a=3'; the first definition always wins. Returns null on error. Always decodes from UTF-8, not any other encoding. @@ -61,8 +63,8 @@ Params = getBool: (query, param, defaultValue) -> val = query[param] return defaultValue if undefined == val - return true if "true" == val || "1" == val || "" == val - return false if "false" == val || "0" == val + return true if 'true' == val || '1' == val || '' == val + return false if 'false' == val || '0' == val return null @@ -81,8 +83,8 @@ Params = result[name] = value if !(name in result) result - # Parse an address in the form "host:port". Returns an Object with keys "host" - # (String) and "port" (int). Returns null on error. + # Parse an address in the form 'host:port'. Returns an Object with keys 'host' + # (String) and 'port' (int). Returns null on error. parseAddress: (spec) -> m = null # IPv6 syntax. @@ -104,191 +106,20 @@ Params = # elems = [] # for k in x # elems.push(maybe_quote(k) + ': ' + repr(x[k])); - # return "{ " + elems.join(", ") + " }"; - # } else if (typeof x === "string") { + # return '{ ' + elems.join(', ') + ' }'; + # } else if (typeof x === 'string') { # return quote(x); # } else { # return x.toString(); -# safe_repr = (s) -> SAFE_LOGGING ? "[scrubbed]" : repr(s) -safe_repr = (s) -> SAFE_LOGGING ? "[scrubbed]" : JSON.stringify(s) +# safe_repr = (s) -> SAFE_LOGGING ? '[scrubbed]' : repr(s) +safe_repr = (s) -> SAFE_LOGGING ? '[scrubbed]' : JSON.stringify(s) # HEADLESS is true if we are running not in a browser with a DOM. DEBUG = false if window && window.location query = Query.parse(window.location.search.substr(1)) - DEBUG = Params.getBool(query, "debug", false) -HEADLESS = "undefined" == typeof(document) - -# TODO: Different ICE servers. -config = { - iceServers: [ - { urls: ["stun:stun.l.google.com:19302"] } - ] -} - -# DOM elements -$chatlog = null -$send = null -$input = null - - -# TODO: Implement -class Badge - - -# Janky state machine -MODE = - INIT: 0 - WEBRTC_CONNECTING: 1 - WEBRTC_READY: 2 - -# Minimum viable snowflake for now - just 1 client. -class Snowflake - - # PeerConnection - pc: null - rateLimit: 0 - proxyPairs: [] - relayAddr: null - badge: null - $badge: null - MAX_NUM_CLIENTS = 1 - CONNECTIONS_PER_CLIENT = 1 - state: MODE.INIT - - constructor: -> - if HEADLESS - # No badge - else if DEBUG - @$badge = debug_div - else - @badge = new Badge() - @$badgem = @badge.elem - @$badge.setAttribute("id", "snowflake-badge") if (@$badge) - - # TODO: User-supplied for now, but should fetch from facilitator later. - setRelayAddr: (relayAddr) -> - addr = Params.parseAddress relayAddr - if !addr - log 'Invalid address spec. Try again.' - return false - @relayAddr = addr - log 'Using ' + relayAddr + ' as Relay.' - log "Input offer from the snowflake client:" - @beginWebRTC() - return true - - # Initialize WebRTC PeerConnection - beginWebRTC: -> - log "Starting up Snowflake..." - @state = MODE.WEBRTC_CONNECTING - - @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 - log "Finished gathering ICE candidates." - Signalling.send @pc.localDescription - - # OnDataChannel triggered remotely from the client when connection succeeds. - @pc.ondatachannel = (dc) => - console.log dc; - channel = dc.channel - log "Data Channel established..." - @prepareDataChannel channel - - prepareDataChannel: (channel) -> - channel.onopen = => - log "Data channel opened!" - @state = MODE.WEBRTC_READY - # This is the point when the WebRTC datachannel is done, so the next step - # is to establish websocket to the server. - @beginProxy(null, @relayAddr) - channel.onclose = => - log "Data channel closed." - @state = MODE.INIT; - $chatlog.className = "" - channel.onerror = => - log "Data channel error!" - channel.onmessage = (msg) => - line = recv = msg.data - console.log(msg); - # Go sends only raw bytes... - if "[object ArrayBuffer]" == recv.toString() - bytes = new Uint8Array recv - line = String.fromCharCode.apply(null, bytes) - line = line.trim() - log "data: " + line - - # Receive an SDP offer from client plugin. - receiveOffer: (desc) => - sdp = new RTCSessionDescription desc - try - err = @pc.setRemoteDescription sdp - catch e - log "Invalid SDP message." - return false - log("SDP " + sdp.type + " successfully received.") - @sendAnswer() if 'offer' == sdp.type - true - - sendAnswer: => - next = (sdp) => - log "webrtc: Answer ready." - @pc.setLocalDescription sdp - promise = @pc.createAnswer next - promise.then next if promise - - # Poll facilitator when this snowflake can support more clients. - proxyMain: -> - if @proxyPairs.length >= @MAX_NUM_CLIENTS * @CONNECTIONS_PER_CLIENT - setTimeout(@proxyMain, @facilitator_poll_interval * 1000) - return - - params = [["r", "1"]] - params.push ["transport", "websocket"] - params.push ["transport", "webrtc"] - - beginProxy: (client, relay) -> - for i in [1..CONNECTIONS_PER_CLIENT] - @makeProxyPair client, relay - - makeProxyPair: (client, relay) -> - pair = new ProxyPair(client, relay, @rate_limit); - @proxyPairs.push pair - pair.onCleanup = (event) => - # Delete from the list of active proxy pairs. - @proxyPairs.splice(@proxy_pairs.indexOf(pair), 1) - @badge.endProxy() if @badge - try - pair.connectRelay() - catch err - log 'ERROR: ProxyPair exception while connecting.' - log err - return - @badge.beginProxy if @badge - - cease: -> - while @proxyPairs.length > 0 - @proxyPairs.pop().close() - - disable: -> - log "Disabling Snowflake." - @cease() - @badge.disable() if @badge - - die: -> - log "Snowflake died." - @cease() - @badge.die() if @badge - + DEBUG = Params.getBool(query, 'debug', false) +HEADLESS = 'undefined' == typeof(document) DEFAULT_PORTS = http: 80 @@ -317,10 +148,10 @@ buildUrl = (scheme, host, port, path, params) -> path = '/' + path ### Slash is significant so we must protect it from encodeURIComponent, while - still encoding question mark and number sign. RFC 3986, section 3.3: "The + still encoding question mark and number sign. RFC 3986, section 3.3: 'The path is terminated by the first question mark ('?') or number sign ('#') character, or by the end of the URI. ... A path consists of a sequence of - path segments separated by a slash ('/') character." + path segments separated by a slash ('/') character.' ### path = path.replace /[^\/]+/, (m) -> encodeURIComponent m @@ -339,59 +170,244 @@ makeWebsocket = (addr) -> # else # ws = new WebSocket url 'base64' ### - "User agents can use this as a hint for how to handle incoming binary data: if + 'User agents can use this as a hint for how to handle incoming binary data: if the attribute is set to 'blob', it is safe to spool it to disk, and if it is - set to 'arraybuffer', it is likely more efficient to keep the data in memory." + set to 'arraybuffer', it is likely more efficient to keep the data in memory.' ### ws.binaryType = 'arraybuffer' ws +# TODO: Different ICE servers. +config = { + iceServers: [ + { urls: ['stun:stun.l.google.com:19302'] } + ] +} +# DOM elements +$chatlog = null +$send = null +$input = null # TODO: Implement +class Badge + +# Janky state machine +MODE = + INIT: 0 + WEBRTC_CONNECTING: 1 + WEBRTC_READY: 2 + +# Minimum viable snowflake for now - just 1 client. +class Snowflake + + MAX_NUM_CLIENTS = 1 + CONNECTIONS_PER_CLIENT = 1 + + relayAddr: null + # TODO: Actually support multiple ProxyPairs. (makes more sense once meek- + # signalling is ready) + proxyPairs: [] + proxyPair: null + + rateLimit: null # TODO + badge: null + $badge: null + state: MODE.INIT + + constructor: -> + if HEADLESS + # No badge + else if DEBUG + @$badge = debug_div + else + @badge = new Badge() + @$badgem = @badge.elem + @$badge.setAttribute('id', 'snowflake-badge') if (@$badge) + + # TODO: User-supplied for now, but should fetch from facilitator later. + setRelayAddr: (relayAddr) -> + addr = Params.parseAddress relayAddr + if !addr + log 'Invalid address spec.' + return false + @relayAddr = addr + log 'Using ' + relayAddr + ' as Relay.' + @beginWebRTC() + log 'Input offer from the snowflake client:' + return true + + # Initialize WebRTC PeerConnection + beginWebRTC: -> + log 'Starting up Snowflake...\n' + @state = MODE.WEBRTC_CONNECTING + for i in [1..CONNECTIONS_PER_CLIENT] + @makeProxyPair @relayAddr + @proxyPair = @proxyPairs[0] + + # Receive an SDP offer from client plugin. + receiveOffer: (desc) => + sdp = new RTCSessionDescription desc + try + err = @proxyPair.pc.setRemoteDescription sdp + catch e + log 'Invalid SDP message.' + return false + log('SDP ' + sdp.type + ' successfully received.') + @sendAnswer() if 'offer' == sdp.type + true + + sendAnswer: => + next = (sdp) => + log 'webrtc: Answer ready.' + @proxyPair.pc.setLocalDescription sdp + promise = @proxyPair.pc.createAnswer next + promise.then next if promise + + # Poll facilitator when this snowflake can support more clients. + proxyMain: -> + if @proxyPairs.length >= @MAX_NUM_CLIENTS * @CONNECTIONS_PER_CLIENT + setTimeout(@proxyMain, @facilitator_poll_interval * 1000) + return + params = [['r', '1']] + params.push ['transport', 'websocket'] + params.push ['transport', 'webrtc'] + + makeProxyPair: (relay) -> + pair = new ProxyPair(null, relay, @rateLimit); + @proxyPairs.push pair + pair.onCleanup = (event) => + # Delete from the list of active proxy pairs. + @proxyPairs.splice(@proxyPairs.indexOf(pair), 1) + @badge.endProxy() if @badge + try + pair.connectClient() + catch err + log 'ERROR: ProxyPair exception while connecting.' + log err + return + @badge.beginProxy if @badge + + cease: -> + while @proxyPairs.length > 0 + @proxyPairs.pop().close() + + disable: -> + log 'Disabling Snowflake.' + @cease() + @badge.disable() if @badge + + die: -> + log 'Snowflake died.' + @cease() + @badge.die() if @badge + + +### +Represents: client <-- webrtc --> snowflake <-- websocket --> relay +### class ProxyPair + MAX_BUFFER: 10 * 1024 * 1024 + pc: null + c2rSchedule: [] + r2cSchedule: [] + client: null # WebRTC Data channel + relay: null # websocket + running: true + flush_timeout_id: null constructor: (@clientAddr, @relayAddr, @rateLimit) -> + @c2rSchedule = [] + @r2cSchedule = [] - # Assumes WebRTC part is already connected. - # TODO: Put the webrtc stuff in ProxyPair, so that multiple webrtc connections - # can be established. + connectClient: -> + @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. + log 'Finished gathering ICE candidates.' + Signalling.send @pc.localDescription + # OnDataChannel triggered remotely from the client when connection succeeds. + @pc.ondatachannel = (dc) => + console.log dc; + channel = dc.channel + log 'Data Channel established...' + @prepareDataChannel channel + @client = channel + + prepareDataChannel: (channel) -> + channel.onopen = => + log 'Data channel opened!' + snowflake.state = MODE.WEBRTC_READY + # 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 'Data channel closed.' + @state = MODE.INIT; + $chatlog.className = '' + channel.onerror = => + log 'Data channel error!' + channel.onmessage = @onClientToRelayMessage + + # Assumes WebRTC datachannel is connected. connectRelay: -> - log "Snowflake: connecting to relay" - - @relay = makeWebsocket(@relayAddr); - @relay.label = 'Relay' + log 'Connecting to relay...' + @relay = makeWebsocket @relayAddr + @relay.label = 'websocket-relay' @relay.onopen = => - log "Snowflake: " + ws.label + "connected" + log 'Relay ' + @relay.label + 'connected' @relay.onclose = @onClose @relay.onerror = @onError @relay.onmessage = @onRelayToClientMessage - onClientToRelayMessage: (event) -> - @c2r_schedule.push event.data + # WebRTC --> websocket + onClientToRelayMessage: (msg) => + line = recv = msg.data + console.log msg + # Go sends only raw bytes... + if '[object ArrayBuffer]' == recv.toString() + bytes = new Uint8Array recv + line = String.fromCharCode.apply(null, bytes) + line = line.trim() + log 'WebRTC-->websocket data: ' + line + @c2rSchedule.push recv @flush() - onRelayToClientMessage: (event) -> - @r2c_schedule.push event.data + # websocket --> WebRTC + onRelayToClientMessage: (event) => + @r2cSchedule.push event.data + log 'websocket-->WebRTC data: ' + event.data @flush() - onClose: (event) -> + onClose: (event) => ws = event.target log(ws.label + ': closed.') @flush() @maybeCleanup() - onError: (event) -> + onError: (event) => ws = event.target log ws.label + ': error.' - this.close(); + @close() # we can't rely on onclose_callback to cleanup, since one common error # case is when the client fails to connect and the relay never starts. # in that case close() is a NOP and onclose_callback is never called. @maybeCleanup() + webrtcIsReady: -> null != @client && 'open' == @client.readyState isOpen: (ws) -> undefined != ws && WebSocket.OPEN == ws.readyState isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState + close: -> + @client.close() if !(isClosed @client) + @relay.close() if !(isClosed @relay) maybeCleanup: -> if @running && @isClosed(client) && @isClosed @relay @@ -402,67 +418,45 @@ class ProxyPair # Send as much data as the rate limit currently allows. flush: -> - ### clearTimeout @flush_timeout_id if @flush_timeout_id @flush_timeout_id = null busy = true - checkChunks = -> + checkChunks = => busy = false - # if @isOpen @clientthis.client_s) && - # this.client_s.bufferedAmount < MAX_BUFFER && - # this.r2c_schedule.length > 0) { - # chunk = this.r2c_schedule.shift(); - # this.rate_limit.update(chunk.length); - # this.client_s.send(chunk); - # busy = true; - # - if @isOpen @relay && - @relay.bufferedAmount < MAX_BUFFER && - @c2r_schedule.length > 0 - chunk = @c2r_schedule.shift() - @rate_limit.update chunk.length + # websocket --> WebRTC + if @webrtcIsReady() && @client.bufferedAmount < @MAX_BUFFER && @r2cSchedule.length > 0 + chunk = @r2cSchedule.shift() + # this.rate_limit.update(chunk.length) + @client.send chunk + busy = true + # WebRTC --> websocket + if (@isOpen @relay) && (@relay.bufferedAmount < @MAX_BUFFER) && @c2rSchedule.length > 0 + chunk = @c2rSchedule.shift() + # @rate_limit.update chunk.length @relay.send chunk busy = true - checkChunks() while busy && !@rate_limit.is_limited() + checkChunks() while busy # && !@rate_limit.is_limited() - if @isClosed @relay && - # !isClosed(this.client_s) && - # @client_s.bufferedAmount === 0 && - @r2c_schedule.length == 0 - # log("Client: closing."); - # this.client_s.close(); - # if (is_closed(this.client_s) && - # !is_closed(this.relay_s) && - # this.relay_s.bufferedAmount === 0 && - # this.c2r_schedule.length === 0) { - # log("Relay: closing."); - # this.relay_s.close(); - # } + # TODO: rate limiting stuff + # if @r2cSchedule.length > 0 || (@client) && @client.bufferedAmount > 0) || @c2rSchedule.length > 0 || (@isOpen(@relay) && @relay.bufferedAmount > 0) + # @flush_timeout_id = setTimeout @flush, @rate_limit.when() * 1000 - - while busy && !@rate_limit.is_limited() - - if this.r2c_schedule.length > 0 || - (@isOpen(@client) && @client.bufferedAmount > 0) || - @c2r_schedule.length > 0 || - (@isOpen(@relay) && @relay.bufferedAmount > 0) - @flush_timeout_id = setTimeout @flush, @rate_limit.when() * 1000 - ### # ## -- DOM & Input Functionality -- ## # snowflake = null welcome = -> - log "== snowflake browser proxy ==" - log "Input desired relay address:" + log '== snowflake browser proxy ==' + log 'Input desired relay address:' # Log to the message window. log = (msg) -> - $chatlog.value += msg + "\n" console.log msg # Scroll to latest - $chatlog.scrollTop = $chatlog.scrollHeight + if $chatlog + $chatlog.value += msg + '\n' + $chatlog.scrollTop = $chatlog.scrollHeight Interface = # Local input from keyboard into message window. @@ -471,44 +465,46 @@ Interface = switch snowflake.state when MODE.INIT # Set target relay. - snowflake.setRelayAddr msg + if !(snowflake.setRelayAddr msg) + log 'Defaulting to websocket relay at ' + DEFAULT_WEBSOCKET + snowflake.setRelayAddr DEFAULT_WEBSOCKET when MODE.WEBRTC_CONNECTING Signalling.receive msg when MODE.WEBRTC_READY - log "No input expected - WebRTC connected." + log 'No input expected - WebRTC connected.' # data = msg # log(data) # channel.send(data) else - log "ERROR: " + msg - $input.value = "" + log 'ERROR: ' + msg + $input.value = '' $input.focus() # Signalling channel - just tells user to copy paste to the peer. # Eventually this should go over the facilitator. Signalling = send: (msg) -> - log "---- Please copy the below to peer ----\n" + log '---- Please copy the below to peer ----\n' log JSON.stringify(msg) - log "\n" + log '\n' receive: (msg) -> - recv = "" + recv = '' try recv = JSON.parse msg catch e - log "Invalid JSON." + log 'Invalid JSON.' return desc = recv['sdp'] if !desc - log "Invalid SDP." + log 'Invalid SDP.' return false snowflake.receiveOffer recv if desc init = -> $chatlog = document.getElementById('chatlog') - $chatlog.value = "" + $chatlog.value = '' $send = document.getElementById('send') $send.onclick = Interface.acceptInput diff --git a/proxy/snowflake_test.coffee b/proxy/snowflake_test.coffee index 8164b6a..9f5a244 100644 --- a/proxy/snowflake_test.coffee +++ b/proxy/snowflake_test.coffee @@ -188,7 +188,14 @@ testParseQueryString = -> else fail test.qs, test.expected, actual +testProxyPair = -> + announce 'testProxyPair' + addr = Params.parseAddress '0.0.0.0:35302' + console.log addr + pair = new ProxyPair(null, addr, 0) + pair.connectRelay() testBuildUrl() testParseCookieString() testParseQueryString() +# testProxyPair()