mirror of
https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake.git
synced 2025-10-13 11:11:30 -04:00
split and organize coffee files, improve Cake build tasks
This commit is contained in:
parent
e433af26f8
commit
fa9f58c9ab
10 changed files with 517 additions and 391 deletions
14
README.md
14
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
|
||||
|
|
|
@ -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'
|
||||
|
|
142
proxy/proxypair.coffee
Normal file
142
proxy/proxypair.coffee
Normal file
|
@ -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
|
14
proxy/shims.coffee
Normal file
14
proxy/shims.coffee
Normal file
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
78
proxy/static/snowflake.html
Normal file
78
proxy/static/snowflake.html
Normal file
|
@ -0,0 +1,78 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
|
||||
<script type="text/javascript" src="snowflake.js"></script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-transition: all 0.3s;
|
||||
-moz-transition: all 0.3s;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
body {
|
||||
position: absolute;
|
||||
width: 100%; height: 100%; top: 0; margin: 0 auto;
|
||||
background-color: #424;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-family: monospace;
|
||||
background-image: url('koch.jpg');
|
||||
}
|
||||
textarea {
|
||||
background-color: rgba(0,0,0,0.8);
|
||||
color: #fff;
|
||||
resize: none;
|
||||
}
|
||||
.chatarea {
|
||||
position: relative; border: none;
|
||||
width: 50%; min-width: 40em;
|
||||
padding: 0.5em; margin: auto;
|
||||
}
|
||||
.active { background-color: rgba(0,50,0,0.8); }
|
||||
#msglog {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40em;
|
||||
margin-bottom: 1em;
|
||||
padding: 8px;
|
||||
}
|
||||
.inputarea {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3em;
|
||||
display: block;
|
||||
}
|
||||
#input {
|
||||
display: inline-block;
|
||||
position: absolute; left: 0;
|
||||
width: 89%; height: 100%;
|
||||
padding: 8px 30px;
|
||||
font-size: 80%;
|
||||
color: #fff;
|
||||
background-color: rgba(0,0,0,0.9);
|
||||
border: 1px solid #999;
|
||||
}
|
||||
#send {
|
||||
display: inline-block; position: absolute;
|
||||
right: 0; top: 0; height: 100%; width: 10%;
|
||||
background-color: #202; color: #f8f;
|
||||
font-variant: small-caps; font-size: 100%;
|
||||
border: none; // box-shadow: 0 2px 5px #000;
|
||||
}
|
||||
#send:hover { background-color: #636; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chatarea">
|
||||
<textarea id="msglog" readonly>
|
||||
</textarea>
|
||||
<div class="inputarea">
|
||||
<input type="text" id="input">
|
||||
<input type="submit" id="send" value="send">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
154
proxy/util.coffee
Normal file
154
proxy/util.coffee
Normal file
|
@ -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
|
56
proxy/websocket.coffee
Normal file
56
proxy/websocket.coffee
Normal file
|
@ -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
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue