From fa9f58c9ab8b46a755a8bc97927346bb6984d655 Mon Sep 17 00:00:00 2001 From: Serene Han Date: Thu, 14 Jan 2016 16:28:32 -0800 Subject: [PATCH] split and organize coffee files, improve Cake build tasks --- README.md | 14 +- proxy/Cakefile | 37 +- .../{snowflake.html => coffee-snowflake.html} | 0 proxy/proxypair.coffee | 142 ++++++ proxy/shims.coffee | 14 + proxy/snowflake.coffee | 413 ++---------------- proxy/{ => static}/koch.jpg | Bin proxy/static/snowflake.html | 78 ++++ proxy/util.coffee | 154 +++++++ proxy/websocket.coffee | 56 +++ 10 files changed, 517 insertions(+), 391 deletions(-) rename proxy/{snowflake.html => coffee-snowflake.html} (100%) create mode 100644 proxy/proxypair.coffee create mode 100644 proxy/shims.coffee rename proxy/{ => static}/koch.jpg (100%) create mode 100644 proxy/static/snowflake.html create mode 100644 proxy/util.coffee create mode 100644 proxy/websocket.coffee diff --git a/README.md b/README.md index 7e8effd..5fd8e06 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,20 @@ At this point the tor client should bootstrap to 100%. #### Snowflake proxy -Otherwise, to connect through the WebRTC proxy in the browser, start a local -http server in the `proxy/` directory however you wish. For instance: +Otherwise, to connect through the WebRTC proxy in the browser, build with: ``` cd proxy/ -python -m http.server +cake build ``` + +Then start a local http server in the `proxy/build/` however you wish +For instance: + +''' +cd build/ +python -m http.server +''' + Open a browser tab to `0.0.0.0:8000/snowflake.html`. The page will ask you to input a relay. Input your desired relay address, or input nothing/gibberish which will cause diff --git a/proxy/Cakefile b/proxy/Cakefile index 52e1bd0..e997b7a 100644 --- a/proxy/Cakefile +++ b/proxy/Cakefile @@ -1,17 +1,44 @@ fs = require 'fs' - {exec} = require 'child_process' +# All coffeescript files required. +FILES = [ + 'shims.coffee' + 'util.coffee' + 'proxypair.coffee' + 'websocket.coffee' + 'snowflake.coffee' +] +OUTFILE = 'build/snowflake.coffee' +STATIC = 'static' + task 'test', 'snowflake unit tests', -> testFile = 'test/snowflake.bundle.coffee' - exec 'cat snowflake.coffee snowflake_test.coffee | cat > ' + testFile, (err, stdout, stderr) -> + exec 'cat ' + FILES.join(' ') + ' snowflake_test.coffee | cat > ' + + testFile, (err, stdout, stderr) -> throw err if err console.log stdout + stderr exec 'coffee ' + testFile + ' -v', (err, stdout, stderr) -> throw err if err console.log stdout + stderr -task 'build', 'build the snowflake proxy', -> - exec 'coffee -o build -c snowflake.coffee', (err, stdout, stderr) -> +concatCoffeeFiles = -> exec 'cat ' + FILES.join(' ') + ' | cat > ' + OUTFILE + +copyStaticFiles = -> exec 'cp ' + STATIC + '/* build/' + +compileCoffee = -> + exec 'coffee -o build -c build/snowflake.coffee', (err, stdout, stderr) -> throw err if err - console.log stdout + stderr + +task 'build:concat', 'concatenate coffeescript files.', concatCoffeeFiles + +task 'build', 'build the snowflake proxy', -> + exec 'mkdir build' + concatCoffeeFiles() + copyStaticFiles() + compileCoffee() + console.log 'Snowflake prepared.' + +task 'clean', 'remove all built files', -> + exec 'rm -r build' + exec 'rm -r test' diff --git a/proxy/snowflake.html b/proxy/coffee-snowflake.html similarity index 100% rename from proxy/snowflake.html rename to proxy/coffee-snowflake.html diff --git a/proxy/proxypair.coffee b/proxy/proxypair.coffee new file mode 100644 index 0000000..478b256 --- /dev/null +++ b/proxy/proxypair.coffee @@ -0,0 +1,142 @@ +### +Represents a single: + + 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) -> + + 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 + $msglog.className = 'active'; + # 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.' + snowflake.state = MODE.INIT; + $msglog.className = '' + channel.onerror = => + log 'Data channel error!' + channel.onmessage = @onClientToRelayMessage + + # Assumes WebRTC datachannel is connected. + connectRelay: => + log 'Connecting to relay...' + @relay = makeWebsocket @relayAddr + @relay.label = 'websocket-relay' + @relay.onopen = => + log '\nRelay ' + @relay.label + ' connected!' + @relay.onclose = @onClose + @relay.onerror = @onError + @relay.onmessage = @onRelayToClientMessage + + # 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() + console.log 'WebRTC --> websocket data: ' + line + @c2rSchedule.push recv + @flush() + + # websocket --> WebRTC + onRelayToClientMessage: (event) => + @r2cSchedule.push event.data + # log 'websocket-->WebRTC data: ' + event.data + @flush() + + onClose: (event) => + ws = event.target + log(ws.label + ': closed.') + @flush() + @maybeCleanup() + + onError: (event) => + ws = event.target + log ws.label + ': error.' + @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 + relayIsReady: -> (null != @relay) && (WebSocket.OPEN == @relay.readyState) + isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState + close: -> + @client.close() if @webrtcIsReady() + @relay.close() if @relayIsReady() + relay = null + + maybeCleanup: => + if @running + @running = false + # TODO: Call external 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 + # WebRTC --> websocket + if @relayIsReady() && @relay.bufferedAmount < @MAX_BUFFER && @c2rSchedule.length > 0 + chunk = @c2rSchedule.shift() + @rateLimit.update chunk.length + @relay.send chunk + busy = true + # websocket --> WebRTC + if @webrtcIsReady() && @client.bufferedAmount < @MAX_BUFFER && @r2cSchedule.length > 0 + chunk = @r2cSchedule.shift() + @rateLimit.update chunk.length + @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 diff --git a/proxy/shims.coffee b/proxy/shims.coffee new file mode 100644 index 0000000..9fc2fb4 --- /dev/null +++ b/proxy/shims.coffee @@ -0,0 +1,14 @@ +### +WebrTC shims for multiple browsers. +### + +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.RTCSessionDescription = window.RTCSessionDescription || + window.mozRTCSessionDescription + diff --git a/proxy/snowflake.coffee b/proxy/snowflake.coffee index 67dc02a..58a294b 100644 --- a/proxy/snowflake.coffee +++ b/proxy/snowflake.coffee @@ -7,137 +7,16 @@ 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. ### - DEFAULT_WEBSOCKET = '192.81.135.242:9901' +DEFAULT_PORTS = + http: 80 + https: 443 -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.RTCSessionDescription = window.RTCSessionDescription || - window.mozRTCSessionDescription - -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 string in strings - 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 not of 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 '&' - -Parse = - # Parse a cookie data string (usually document.cookie). The return type is an - # object mapping cookies names to values. Returns null on error. - # http://www.w3.org/TR/DOM-Level-2-HTML/html.html#ID-8747038 - cookie: (cookies) -> - result = {} - strings = [] - strings = cookies.split ';' if cookies - for string in strings - j = string.indexOf '=' - return null if -1 == j - name = decodeURIComponent string.substr(0, j).trim() - value = decodeURIComponent string.substr(j + 1).trim() - 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. - address: (spec) -> - m = null - # IPv6 syntax. - m = spec.match(/^\[([\0-9a-fA-F:.]+)\]:([0-9]+)$/) if !m - # IPv4 syntax. - m = spec.match(/^([0-9.]+):([0-9]+)$/) if !m - return null if !m - - host = m[1] - port = parseInt(m[2], 10) - if isNaN(port) || port < 0 || port > 65535 - return null - { host: host, port: port } - - # Parse a count of bytes. A suffix of 'k', 'm', or 'g' (or uppercase) - # does what you would think. Returns null on error. - byteCount: (spec) -> - UNITS = { - k: 1024, m: 1024 * 1024, g: 1024 * 1024 * 1024 - K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 - } - matches = spec.match /^(\d+(?:\.\d*)?)(\w*)$/ - return null if null == matches - count = Number matches[1] - return null if isNaN count - if '' == matches[2] - units = 1 - else - units = UNITS[matches[2]] - return null if null == units - count * Number(units) - -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 - - # Get an object value and parse it as a byte count. Example byte counts are - # '100' and '1.3m'. Returns |defaultValue| if param is not a key. Return null on - # a parsing error. - getByteCount: (query, param, defaultValue) -> - spec = query[param] - return defaultValue if undefined == spec - Parse.byteCount spec - - # Get an object value and parse it as an address spec. Returns |defaultValue| - # if param is not a key. Returns null on a parsing error. - getAddress: (query, param, defaultValue) -> - val = query[param] - return defaultValue if undefined == val - Parse.address val - - -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 is true if we are running not in a browser with a DOM. HEADLESS = 'undefined' == typeof(document) # Bytes per second. Set to undefined to disable limit. @@ -145,102 +24,8 @@ DEFAULT_RATE_LIMIT = DEFAULT_RATE_LIMIT || undefined MIN_RATE_LIMIT = 10 * 1024 RATE_LIMIT_HISTORY = 5.0 -DEFAULT_PORTS = - http: 80 - https: 443 - -# DOM elements. -$msglog = null -$send = null -$input = null - -# 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 - -class BucketRateLimit - amount: 0.0 - lastUpdate: new Date() - - constructor: (@capacity, @time) -> - - age: -> - now = new Date() - delta = (now - @lastUpdate) / 1000.0 - @lastUpdate = now - @amount -= delta * @capacity / @time - @amount = 0.0 if @amount < 0.0 - - update: (n) -> - @age() - @amount += n - @amount <= @capacity - - # How many seconds in the future will the limit expire? - when: -> - age() - (@amount - @capacity) / (@capacity / @time) - - isLimited: -> - @age() - @amount > @capacity - -# A rate limiter that never limits. -class DummyRateLimit - constructor: (@capacity, @time) -> - update: (n) -> true - when: -> 0.0 - isLimited: -> false - +MAX_NUM_CLIENTS = 1 +CONNECTIONS_PER_CLIENT = 1 # TODO: Different ICE servers. config = { @@ -249,7 +34,6 @@ config = { ] } - # TODO: Implement class Badge @@ -262,9 +46,6 @@ MODE = # 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) @@ -295,7 +76,7 @@ class Snowflake @rateLimit = new BucketRateLimit(rateLimitBytes * RATE_LIMIT_HISTORY, RATE_LIMIT_HISTORY) - # TODO: User-supplied for now, but should fetch from facilitator later. + # TODO: Should fetch from facilitator later. setRelayAddr: (relayAddr) -> addr = Parse.address relayAddr if !addr @@ -323,7 +104,7 @@ class Snowflake catch e log 'Invalid SDP message.' return false - log('SDP ' + sdp.type + ' successfully received.') + log 'SDP ' + sdp.type + ' successfully received.' @sendAnswer() if 'offer' == sdp.type true @@ -336,7 +117,7 @@ class Snowflake # Poll facilitator when this snowflake can support more clients. proxyMain: -> - if @proxyPairs.length >= @MAX_NUM_CLIENTS * @CONNECTIONS_PER_CLIENT + if @proxyPairs.length >= MAX_NUM_CLIENTS * CONNECTIONS_PER_CLIENT setTimeout(@proxyMain, @facilitator_poll_interval * 1000) return params = [['r', '1']] @@ -372,159 +153,16 @@ class Snowflake @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) -> - - 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 - $msglog.className = 'active'; - # 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.' - snowflake.state = MODE.INIT; - $msglog.className = '' - channel.onerror = => - log 'Data channel error!' - channel.onmessage = @onClientToRelayMessage - - # Assumes WebRTC datachannel is connected. - connectRelay: => - log 'Connecting to relay...' - @relay = makeWebsocket @relayAddr - @relay.label = 'websocket-relay' - @relay.onopen = => - log '\nRelay ' + @relay.label + ' connected!' - @relay.onclose = @onClose - @relay.onerror = @onError - @relay.onmessage = @onRelayToClientMessage - - # 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() - console.log 'WebRTC --> websocket data: ' + line - @c2rSchedule.push recv - @flush() - - # websocket --> WebRTC - onRelayToClientMessage: (event) => - @r2cSchedule.push event.data - # log 'websocket-->WebRTC data: ' + event.data - @flush() - - onClose: (event) => - ws = event.target - log(ws.label + ': closed.') - @flush() - @maybeCleanup() - - onError: (event) => - ws = event.target - log ws.label + ': error.' - @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 - relayIsReady: -> (null != @relay) && (WebSocket.OPEN == @relay.readyState) - isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState - close: -> - @client.close() if @webrtcIsReady() - @relay.close() if @relayIsReady() - relay = null - - maybeCleanup: => - if @running - @running = false - # TODO: Call external 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 - # WebRTC --> websocket - if @relayIsReady() && @relay.bufferedAmount < @MAX_BUFFER && @c2rSchedule.length > 0 - chunk = @c2rSchedule.shift() - @rateLimit.update chunk.length - @relay.send chunk - busy = true - # websocket --> WebRTC - if @webrtcIsReady() && @client.bufferedAmount < @MAX_BUFFER && @r2cSchedule.length > 0 - chunk = @r2cSchedule.shift() - @rateLimit.update chunk.length - @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 - -# -## -- DOM & Input Functionality -- ## -# snowflake = null -welcome = -> - log '== snowflake browser proxy ==' - log 'Input desired relay address:' +# +## -- DOM & Inputs -- # +# -# Log to the message window. -log = (msg) -> - console.log msg - # Scroll to latest - if $msglog - $msglog.value += msg + '\n' - $msglog.scrollTop = $msglog.scrollHeight +# DOM elements references. +$msglog = null +$send = null +$input = null Interface = # Local input from keyboard into message window. @@ -550,7 +188,7 @@ Interface = Signalling = send: (msg) -> log '---- Please copy the below to peer ----\n' - log JSON.stringify(msg) + log JSON.stringify msg log '\n' receive: (msg) -> @@ -566,8 +204,18 @@ Signalling = return false snowflake.receiveOffer recv if desc -init = -> +log = (msg) -> # Log to the message window. + console.log msg + # Scroll to latest + if $msglog + $msglog.value += msg + '\n' + $msglog.scrollTop = $msglog.scrollHeight +welcome = -> + log '== snowflake browser proxy ==' + log 'Input desired relay address:' + +init = -> $msglog = document.getElementById('msglog') $msglog.value = '' @@ -576,9 +224,8 @@ init = -> $input = document.getElementById('input') $input.focus() - $input.onkeydown = (e) => - if 13 == e.keyCode # enter - $send.onclick() + $input.onkeydown = (e) => $send.onclick() if 13 == e.keyCode # enter + snowflake = new Snowflake() window.snowflake = snowflake welcome() diff --git a/proxy/koch.jpg b/proxy/static/koch.jpg similarity index 100% rename from proxy/koch.jpg rename to proxy/static/koch.jpg diff --git a/proxy/static/snowflake.html b/proxy/static/snowflake.html new file mode 100644 index 0000000..2f9c765 --- /dev/null +++ b/proxy/static/snowflake.html @@ -0,0 +1,78 @@ + + + + + + + + +
+ +
+ + +
+
+ + + diff --git a/proxy/util.coffee b/proxy/util.coffee new file mode 100644 index 0000000..de7fb33 --- /dev/null +++ b/proxy/util.coffee @@ -0,0 +1,154 @@ +### +A Coffeescript WebRTC snowflake proxy + +Contains helpers for parsing query strings and other utilities. +### + + +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 string in strings + 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 not of 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 '&' + + +Parse = + # Parse a cookie data string (usually document.cookie). The return type is an + # object mapping cookies names to values. Returns null on error. + # http://www.w3.org/TR/DOM-Level-2-HTML/html.html#ID-8747038 + cookie: (cookies) -> + result = {} + strings = [] + strings = cookies.split ';' if cookies + for string in strings + j = string.indexOf '=' + return null if -1 == j + name = decodeURIComponent string.substr(0, j).trim() + value = decodeURIComponent string.substr(j + 1).trim() + 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. + address: (spec) -> + m = null + # IPv6 syntax. + m = spec.match(/^\[([\0-9a-fA-F:.]+)\]:([0-9]+)$/) if !m + # IPv4 syntax. + m = spec.match(/^([0-9.]+):([0-9]+)$/) if !m + return null if !m + + host = m[1] + port = parseInt(m[2], 10) + if isNaN(port) || port < 0 || port > 65535 + return null + { host: host, port: port } + + # Parse a count of bytes. A suffix of 'k', 'm', or 'g' (or uppercase) + # does what you would think. Returns null on error. + byteCount: (spec) -> + UNITS = { + k: 1024, m: 1024 * 1024, g: 1024 * 1024 * 1024 + K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 + } + matches = spec.match /^(\d+(?:\.\d*)?)(\w*)$/ + return null if null == matches + count = Number matches[1] + return null if isNaN count + if '' == matches[2] + units = 1 + else + units = UNITS[matches[2]] + return null if null == units + count * Number(units) + + +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 + + # Get an object value and parse it as a byte count. Example byte counts are + # '100' and '1.3m'. Returns |defaultValue| if param is not a key. Return null on + # a parsing error. + getByteCount: (query, param, defaultValue) -> + spec = query[param] + return defaultValue if undefined == spec + Parse.byteCount spec + + # Get an object value and parse it as an address spec. Returns |defaultValue| + # if param is not a key. Returns null on a parsing error. + getAddress: (query, param, defaultValue) -> + val = query[param] + return defaultValue if undefined == val + Parse.address val + + +class BucketRateLimit + amount: 0.0 + lastUpdate: new Date() + + constructor: (@capacity, @time) -> + + age: -> + now = new Date() + delta = (now - @lastUpdate) / 1000.0 + @lastUpdate = now + @amount -= delta * @capacity / @time + @amount = 0.0 if @amount < 0.0 + + update: (n) -> + @age() + @amount += n + @amount <= @capacity + + # How many seconds in the future will the limit expire? + when: -> + age() + (@amount - @capacity) / (@capacity / @time) + + isLimited: -> + @age() + @amount > @capacity + + +# A rate limiter that never limits. +class DummyRateLimit + constructor: (@capacity, @time) -> + update: (n) -> true + when: -> 0.0 + isLimited: -> false diff --git a/proxy/websocket.coffee b/proxy/websocket.coffee new file mode 100644 index 0000000..b82f902 --- /dev/null +++ b/proxy/websocket.coffee @@ -0,0 +1,56 @@ +### +Only websocket-specific stuff. +### + + +# 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, '/' + # TODO: Do we need to worry about the base64 version? + ws = new WebSocket url + ### + '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 +