diff --git a/proxy/snowflake.coffee b/proxy/snowflake.coffee new file mode 100644 index 0000000..2723773 --- /dev/null +++ b/proxy/snowflake.coffee @@ -0,0 +1,471 @@ +### +A Coffeescript WebRTC snowflake proxy +Using Copy-paste signaling for now. + +Uses WebRTC from the client, and websocket to the server. + +Assume that the webrtc client plugin is always the offerer, in which case +this must always act as the answerer. + +TODO(keroserene): Complete the websocket + webrtc ProxyPair +### + +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 + error. + + Always decodes from UTF-8, not any other encoding. + http://dev.w3.org/html5/spec/Overview.html#url-encoded-form-data + ### + parse: (qs) -> + result = {} + strings = [] + strings = qs.split '&' if qs + return result if 0 == strings.length + for i in [1..strings.length] + string = strings[i] + j = string.indexOf '=' + if j == -1 + name = string + value = '' + else + name = string.substr(0, j) + value = string.substr(j + 1) + name = decodeURIComponent(name.replace(/\+/g, ' ')) + value = decodeURIComponent(value.replace(/\+/g, ' ')) + result[name] = value if !(name in result) + result + + # params is a list of (key, value) 2-tuples. + buildString: (params) -> + parts = [] + for param in params + parts.push encodeURIComponent(param[0]) + '=' + + encodeURIComponent(param[1]) + parts.join '&' + +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 null + +# repr = (x) -> + # return 'null' if null == x + # return 'undefined' if 'undefined' == typeof x + # if 'object' == typeof x + # elems = [] + # for k in x + # elems.push(maybe_quote(k) + ': ' + repr(x[k])); + # 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) + +# HEADLESS is true if we are running not in a browser with a DOM. +query = Query.parse(window.location.search.substr(1)) +HEADLESS = "undefined" == typeof(document) +DEBUG = Params.getBool(query, "debug", false) + +# TODO: Different ICE servers. +config = { + iceServers: [ + { urls: ["stun:stun.l.google.com:19302"] } + ] +} + +# DOM elements +$chatlog = null +$send = null +$input = null + +window.PeerConnection = window.RTCPeerConnection || + window.mozRTCPeerConnection || + window.webkitRTCPeerConnection +window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate; +window.RTCSessionDescription = window.RTCSessionDescription || + window.mozRTCSessionDescription + +# 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: [] + 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 + if (@$badge) + @$badge.setAttribute("id", "snowflake-badge") + + # 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 + # TODO: Prepare ProxyPair onw. + @beginProxy() + 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 + + +# Build an escaped URL string from unescaped components. Only scheme and host +# are required. See RFC 3986, section 3. +buildUrl = (scheme, host, port, path, params) -> + parts = [] + parts.push(encodeURIComponent scheme) + parts.push '://' + + # If it contains a colon but no square brackets, treat it as IPv6. + if host.match(/:/) && !host.match(/[[\]]/) + parts.push '[' + parts.push host + parts.push ']' + else + parts.push(encodeURIComponent host) + + if undefined != port && DEFAULT_PORTS[scheme] != port + parts.push ':' + parts.push(encodeURIComponent port.toString()) + + if undefined != path && '' != path + if !path.match(/^\//) + 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 + 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 = path.replace /[^\/]+/, (m) -> + encodeURIComponent m + parts.push path + + if undefined != params + parts.push '?' + parts.push Query.buildString params + + parts.join '' + +makeWebsocket = (addr) -> + url = buildUrl 'ws', addr.host, addr.port, '/' + if have_websocket_binary_frames() + ws = new WebSocket url + else + ws = new WebSocket url 'base64' + ### + "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." + ### + ws.binaryType = 'arraybuffer' + ws + + + +# TODO: Implement +class ProxyPair + + # TODO: Hardcoded for now, but should fetch from facilitator later. + relayAddr: null + + constructor: (@clientAddr, @relayAddr, @rateLimit) -> + + # Assumes WebRTC part is already connected. + # TODO: Put the webrtc stuff in ProxyPair, so that multiple webrtc connections + # can be established. + connectRelay: -> + log "Snowflake: connecting to relay" + + @relay = makeWebsocket(@relayAddr); + @relay.label = 'Relay' + @relay.onopen = => + log "Snowflake: " + ws.label + "connected" + @relay.onclose = @onClose + @relay.onerror = @onError + @relay.onmessage = @onRelayToClientMessage + + onClientToRelayMessage: (event) -> + @c2r_schedule.push event.data + @flush() + + onRelayToClientMessage: (event) -> + @r2c_schedule.push event.data + @flush() + + onClose: (event) -> + ws = event.target + log(ws.label + ': closed.') + @flush() + @maybeCleanup() + + onError: (event) -> + ws = event.target + log ws.label + ': error.' + this.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() + + isOpen: (ws) -> undefined != ws && WebSocket.OPEN == ws.readyState + isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState + + maybe_cleanup: -> + if @running && @isClosed(client) && @isClosed @relay + @running = false + @cleanup_callback() + true + false + + # 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 = -> + 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 + @relay.send chunk + busy = true + 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(); + # } + + + 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 offer from the snowflake client:" + +# Log to the message window. +log = (msg) -> + $chatlog.value += msg + "\n" + console.log msg + # Scroll to latest + $chatlog.scrollTop = $chatlog.scrollHeight + +Interface = + # Local input from keyboard into message window. + acceptInput: -> + msg = $input.value + switch snowflake.state + when MODE.WEBRTC_CONNECTING + Signalling.receive msg + when MODE.WEBRTC_READY + log "No input expected - WebRTC connected." + # data = msg + # log(data) + # channel.send(data) + else + 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 JSON.stringify(msg) + log "\n" + + receive: (msg) -> + recv = "" + try + recv = JSON.parse msg + catch e + log "Invalid JSON." + return + desc = recv['sdp'] + if !desc + log "Invalid SDP." + return false + snowflake.receiveOffer recv if desc + +init = -> + $chatlog = document.getElementById('chatlog') + $chatlog.value = "" + + $send = document.getElementById('send') + $send.onclick = Interface.acceptInput + + $input = document.getElementById('input') + $input.focus() + $input.onkeydown = (e) => + if 13 == e.keyCode # enter + $send.onclick() + snowflake = new Snowflake() + snowflake.beginWebRTC() + welcome() + +window.onload = init diff --git a/proxy/snowflake.html b/proxy/snowflake.html index cff00a9..55adc42 100644 --- a/proxy/snowflake.html +++ b/proxy/snowflake.html @@ -2,7 +2,8 @@
- + +