├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── exwebrtc.ex └── exwebrtc │ ├── answer_handler.ex │ ├── sdp.ex │ ├── stun.ex │ ├── stun_server.ex │ └── supervisor.ex ├── mix.exs ├── mix.lock ├── priv └── static │ ├── index.html │ ├── main.css │ └── script.js └── test ├── exwebrtc_test.exs ├── integration_test.exs ├── sdp_test.exs ├── stun_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: erlang 2 | notifications: 3 | recipients: 4 | - myers@maski.org 5 | otp_release: 6 | - 17.0 7 | before_install: 8 | - wget https://github.com/elixir-lang/elixir/releases/download/v0.13.1/Precompiled.zip 9 | - unzip -d elixir Precompiled.zip 10 | before_script: 11 | - export PATH=`pwd`/elixir/bin:$PATH 12 | - mix local.hex --force 13 | script: "MIX_ENV=test mix do deps.get, test" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exwebrtc - WebRTC for Elixir - a work in progress 2 | 3 | WebRTC allows browser to directly connect to each other and stream audio/video and data. One cool trick this allows is sending datagram packets between browsers, which are useful for fast pace games where a little bit of packet loss would ruin your day if you where using TCP (like WebSockets). My goal here is to create library that allows me to write a game server that uses WebRTC Data Channels between the server and web browser based client. 4 | 5 | The downside of WebRTC is it's a amalgamation of protocols that aren't yet well supported. To communicate with a WebRTC data channel a server needs to: 6 | * Multiplex all communication over one UDP socket 7 | * Listen for a STUN request and respond 8 | * Send it's own STUN request and read the response 9 | * Accept a DTLS client connection and extract the SCTP packet 10 | * Parse the SCTP and feed your application the data 11 | 12 | # Current status 13 | 14 | The demo can use STUN to negotiate what ports to use. With Firefox (and some tweaking of the SDP to put in the right IP addresses) I can get the browser to start the DTLS handshake. Now working on reading those DTLS packets. 15 | 16 | # Future plans 17 | 18 | I'm hoping to help support development of Erlang's DTLS library. It's partially complete in Erlang 17. I'm hoping that the current SCTP library in Erlang will be able to parse the packets created by Browsers, but perhaps there is a disagreement on the standards. -------------------------------------------------------------------------------- /lib/exwebrtc.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc do 2 | use Application.Behaviour 3 | 4 | def start(_type, [:test]) do 5 | # don't run the app in test mode 6 | {:ok, self} 7 | end 8 | def start(_type, _args) do 9 | dispatch = [ 10 | {:_, [ 11 | {"/", :cowboy_static, {:priv_file, :exwebrtc, "static/index.html"}}, 12 | {"/answer_sdp", Exwebrtc.AnswerHandler, []}, 13 | {"/[...]", :cowboy_static, {:priv_dir, :exwebrtc, "static"}}, 14 | ]} 15 | ] |> :cowboy_router.compile 16 | 17 | {:ok, _} = :cowboy.start_http(:http, 100, [port: 8080], [ 18 | env: [dispatch: dispatch], 19 | ]) 20 | IO.puts "Starting server http://localhost:8080/ ... (Ctrl-c, q, to stop)" 21 | 22 | Exwebrtc.Supervisor.start_link 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/exwebrtc/answer_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.AnswerHandler do 2 | def init(_transport, req, []) do 3 | {:ok, req, nil} 4 | end 5 | 6 | def handle(req, state) do 7 | {:ok, body, req} = :cowboy_req.body(req) 8 | {:ok, decoded} = JSEX.decode(body) 9 | sdp = Dict.get(decoded, "sdp") 10 | IO.puts inspect(sdp) 11 | Exwebrtc.STUNServer.answer_sdp(sdp) 12 | 13 | {:ok, req} = :cowboy_req.reply(200, [], "", req) 14 | {:ok, req, state} 15 | end 16 | 17 | def terminate(_reason, _req, _state), do: :ok 18 | end -------------------------------------------------------------------------------- /lib/exwebrtc/sdp.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.SDP do 2 | def password(sdp) do 3 | sdp_value(sdp, "a=ice-pwd:") 4 | end 5 | def username(sdp) do 6 | sdp_value(sdp, "a=ice-ufrag:") 7 | end 8 | 9 | defp sdp_value(sdp, prefix) do 10 | List.last(String.split(find_line(sdp, prefix), ":", global: true)) 11 | end 12 | 13 | defp find_line(sdp, prefix) do 14 | sdp |> String.split("\r\n") |> Enum.find(fn line -> String.starts_with?(line, prefix) end) 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /lib/exwebrtc/stun.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.STUN do 2 | use Bitwise 3 | 4 | @magic_cookie << 33, 18, 164, 66 >> 5 | @fingerprint_mask 0x5354554e 6 | 7 | @attributes_id_to_name %{ 8 | 0x0001 => :mapped_address, 9 | 0x0002 => :response_address, 10 | 0x0003 => :change_request, 11 | 0x0004 => :source_address, 12 | 0x0005 => :changed_address, 13 | 0x0006 => :username, 14 | 0x0007 => :password, 15 | 0x0008 => :message_integrity, 16 | 0x0009 => :error_code, 17 | 0x000a => :unknown_attributes, 18 | 0x000b => :reflected_from, 19 | 0x0020 => :xor_mapped_address, 20 | 0x8028 => :fingerprint, 21 | 0x8022 => :software, 22 | 0x8023 => :alternate_server, 23 | # from https://tools.ietf.org/html/rfc5245 24 | 0x0024 => :priority, 25 | 0x0025 => :use_candidate, 26 | 0x8029 => :ice_controlled, 27 | 0x802a => :ice_controlling, 28 | } 29 | @attributes_name_to_id Enum.reduce(@attributes_id_to_name, %{}, fn({k, v}, acc) -> Dict.put(acc, v, k) end) 30 | @request_type_id_to_name %{ 31 | 0x0001 => :request, 32 | 0x0101 => :response, 33 | 0x0111 => :error, 34 | } 35 | @request_type_name_to_id Enum.reduce(@request_type_id_to_name, %{}, fn({k, v}, acc) -> Dict.put(acc, v, k) end) 36 | 37 | def parse(packet, hmac_key_callback) do 38 | try do 39 | results = %{} 40 | << request_type_id :: size(16), attributes_size :: size(16), transaction_id :: [binary, size(16)], attributes :: binary >> = packet 41 | results = Dict.put(results, :attributes_size, attributes_size) 42 | results = Dict.put(results, :request_type, @request_type_id_to_name[request_type_id]) 43 | results = Dict.put(results, :transaction_id, transaction_id) 44 | if attributes_size != iodata_size(attributes) do 45 | raise "attributes size is incorrect, garbled packet?" 46 | end 47 | results = parse_attributes(results, binary_part(attributes, 0, attributes_size)) 48 | 49 | if Dict.has_key?(results, :fingerprint) do 50 | verify_fingerprint(packet, results[:fingerprint]) 51 | end 52 | if Dict.has_key?(results, :message_integrity) do 53 | verify_message_integrity(packet, results, hmac_key_callback) 54 | end 55 | {:ok, results} 56 | rescue 57 | e in RuntimeError -> {:error, e.message} 58 | end 59 | end 60 | 61 | def build_request(attribs) do 62 | transaction_id = if Dict.has_key?(attribs, :transaction_id) do 63 | attribs[:transaction_id] 64 | else 65 | [@magic_cookie, :crypto.rand_bytes(12)] 66 | end 67 | header = [<< @request_type_name_to_id[:request] :: size(16)>>, << 0 :: size(16) >>, transaction_id] 68 | 69 | attribute_order = [:username, :use_candidate, :priority, :ice_controlling, :ice_controlled] 70 | attributes = Enum.map(attribute_order, fn(attrib_name) -> 71 | if Dict.has_key?(attribs, attrib_name) do 72 | encode_attribute(attrib_name, Dict.get(attribs, attrib_name)) 73 | end 74 | end) |> Enum.reject(&is_atom/1) 75 | 76 | if Dict.has_key?(attribs, :message_integrity_key) do 77 | [header, attributes] = add_message_integrity(attribs[:message_integrity_key], header, attributes) 78 | end 79 | packet = add_fingerprint(header, attributes) 80 | {:ok, packet} 81 | end 82 | 83 | def add_fingerprint(header, attributes) do 84 | header = List.replace_at(header, 1, << iodata_size(attributes) + 8 :: size(16) >>) 85 | attributes = attributes ++ [encode_attribute(:fingerprint, [header, attributes])] 86 | [header, attributes] 87 | end 88 | 89 | def add_message_integrity(key, header, attributes) do 90 | header = List.replace_at(header, 1, << iodata_size(attributes) + 24 :: size(16) >>) 91 | packet_mac = :crypto.hmac(:sha, key, iodata_to_binary([header, attributes])) 92 | attributes = attributes ++ [encode_attribute(:message_integrity, packet_mac)] 93 | [header, attributes] 94 | end 95 | 96 | def build_reply(attribs) do 97 | if !Dict.has_key?(attribs, :transaction_id) do 98 | raise "must supply transaction_id" 99 | end 100 | 101 | header = [<< @request_type_name_to_id[:response] :: size(16)>>, << 0 :: size(16) >>, attribs[:transaction_id]] 102 | 103 | attributes = [encode_attribute(:xor_mapped_address, attribs[:mapped_address])] 104 | if Dict.has_key?(attribs, :message_integrity_key) do 105 | [header, attributes] = add_message_integrity(attribs[:message_integrity_key], header, attributes) 106 | end 107 | packet = add_fingerprint(header, attributes) 108 | {:ok, packet} 109 | end 110 | 111 | def string_xor(s1, s2) do 112 | s1 = bitstring_to_list(s1) 113 | s2 = bitstring_to_list(s2) 114 | Enum.zip(s1, s2) |> Enum.map(fn {a, b} -> a^^^b end) |> iodata_to_binary 115 | end 116 | 117 | def ip_address_to_binary(ip_addr) do 118 | {:ok, {a, b, c, d}} = ip_addr |> to_char_list |> :inet.parse_address 119 | iodata_to_binary([a, b, c, d]) 120 | end 121 | 122 | def encode_attribute(:priority, value) do 123 | encode_attribute_header(:priority, << value :: size(32) >>) 124 | end 125 | def encode_attribute(:fingerprint, value) do 126 | value = :erlang.crc32(value) ^^^ @fingerprint_mask 127 | encode_attribute_header(:fingerprint, << value :: size(32) >>) 128 | end 129 | def encode_attribute(:ice_controlled, value) do 130 | encode_attribute_header(:ice_controlled, << value :: size(64) >>) 131 | end 132 | def encode_attribute(:ice_controlling, value) do 133 | encode_attribute_header(:ice_controlling, << value :: size(64) >>) 134 | end 135 | def encode_attribute(:xor_mapped_address, {ip_addr, port}) do 136 | ip_addr = ip_addr |> ip_address_to_binary |> string_xor(@magic_cookie) 137 | family = <<1 :: size(16)>> 138 | port = <> |> string_xor(@magic_cookie) 139 | encode_attribute_header(:xor_mapped_address, [family, port, ip_addr]) 140 | end 141 | def encode_attribute(attr_type, value) do 142 | encode_attribute_header(attr_type, value) 143 | end 144 | 145 | def padding_size(attribute_size) do 146 | (4 * Float.ceil(attribute_size / 4)) - attribute_size 147 | end 148 | 149 | def padding(attribute_size) do 150 | List.duplicate(0, padding_size(attribute_size)) 151 | end 152 | 153 | def encode_attribute_header(attr_type, nil) do 154 | [<< @attributes_name_to_id[attr_type] :: size(16) >>, << 0 :: size(16) >>] 155 | end 156 | def encode_attribute_header(attr_type, value) do 157 | attribute_size = iodata_size(value) 158 | [<< @attributes_name_to_id[attr_type] :: size(16) >>, << attribute_size :: size(16) >>, value, padding(attribute_size)] 159 | end 160 | 161 | def parse_attributes(results, << attribute_id :: size(16), attribute_size :: size(16), rest :: binary >>) do 162 | ps = padding_size(attribute_size) 163 | << value :: [binary, size(attribute_size)], _padding :: [binary, size(ps)], next_attribute :: binary >> = rest 164 | value = parse_attribute_value(@attributes_id_to_name[attribute_id], value) 165 | if value do 166 | key = if @attributes_id_to_name[attribute_id] == :xor_mapped_address do 167 | :mapped_address 168 | else 169 | @attributes_id_to_name[attribute_id] 170 | end 171 | results = Dict.put(results, key, value) 172 | end 173 | parse_attributes(results, next_attribute) 174 | end 175 | def parse_attributes(results, ""), do: results 176 | 177 | def parse_attribute_value(:username, value), do: value 178 | def parse_attribute_value(:priority, << value :: size(32) >> ), do: value 179 | def parse_attribute_value(:ice_controlled, << value :: size(64) >>), do: value 180 | def parse_attribute_value(:ice_controlling, << value :: size(64) >>), do: value 181 | 182 | def parse_attribute_value(:xor_mapped_address, value) do 183 | <> = value 184 | if family != 1 do 185 | raise "IPv6 not supported" 186 | end 187 | ip_addr = ip_addr |> string_xor(@magic_cookie) 188 | << a :: size(8), b :: size(8), c :: size(8), d :: size(8) >> = ip_addr 189 | port = port |> string_xor(@magic_cookie) 190 | << port :: size(16) >> = port 191 | {"#{a}.#{b}.#{c}.#{d}", port} 192 | end 193 | def parse_attribute_value(_name, value), do: value 194 | 195 | def verify_fingerprint(packet, << fingerprint :: size(32) >>) do 196 | packet_crc32 = :erlang.crc32(binary_part(packet, 0, iodata_size(packet) - 8)) ^^^ @fingerprint_mask 197 | if packet_crc32 != fingerprint do 198 | raise "bad fingerprint" 199 | end 200 | end 201 | 202 | def verify_message_integrity(packet, results, hmac_key_callback) do 203 | # change the length in header 204 | adjusted_attribs_size = results[:attributes_size] - 8 205 | packet_for_hmac_check = binary_part(packet, 0, 2) <> << adjusted_attribs_size :: size(16) >> <> binary_part(packet, 4, iodata_size(packet) - 4 - 8 - 24) 206 | 207 | # compute mac 208 | hmac_key = hmac_key_callback.(results) 209 | packet_mac = :crypto.hmac(:sha, hmac_key, packet_for_hmac_check) 210 | if packet_mac != results[:message_integrity] do 211 | raise "invalid message integrity" 212 | end 213 | end 214 | 215 | end 216 | -------------------------------------------------------------------------------- /lib/exwebrtc/stun_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.STUNServer do 2 | use ExActor.Strict, export: :stun_server 3 | alias Exwebrtc.STUN, as: STUN 4 | alias Exwebrtc.SDP, as: SDP 5 | 6 | definit port_number do 7 | {:ok, socket} = :gen_udp.open(port_number, [:binary, {:active, :true}]) 8 | initial_state(%{socket: socket}) 9 | end 10 | 11 | defcast answer_sdp(sdp), state: state do 12 | state = Dict.put(state, :sdp, sdp) 13 | if Dict.has_key?(state, :ready_to_probe) do 14 | probe(state) 15 | end 16 | new_state(state) 17 | end 18 | 19 | def probe(%{attributes: attributes, sdp: sdp, ip_addr: ip_addr, in_port_no: in_port_no, socket: socket} = state) do 20 | {:ok, request} = STUN.build_request( 21 | ice_controlling: attributes[:ice_controlled], 22 | priority: attributes[:priority], 23 | username: reverse_username(attributes[:username]), 24 | use_candidate: nil, 25 | message_integrity_key: SDP.password(sdp) 26 | ) 27 | IO.puts inspect(request) 28 | :gen_udp.send(socket, ip_addr, in_port_no, request) 29 | end 30 | 31 | def reverse_username(username) do 32 | username |> String.split(":") |> Enum.reverse() |> Enum.join(":") 33 | end 34 | 35 | definfo {:udp, socket, ip_addr, in_port_no, packet}, state: state do 36 | {:ok, attributes} = STUN.parse(packet, fn x -> "9b4424d9e8c5e253c0290d63328b55b3" end) 37 | if attributes[:request_type] == :request do 38 | {:ok, reply} = STUN.build_reply( 39 | transaction_id: attributes[:transaction_id], 40 | mapped_address: {Enum.join(tuple_to_list(ip_addr), "."), in_port_no}, 41 | message_integrity_key: "9b4424d9e8c5e253c0290d63328b55b3", 42 | ) 43 | :gen_udp.send(socket, ip_addr, in_port_no, reply) 44 | 45 | state = Dict.put(state, :ready_to_probe, true) 46 | state = Dict.put(state, :ip_addr, ip_addr) 47 | state = Dict.put(state, :in_port_no, in_port_no) 48 | state = Dict.put(state, :attributes, attributes) 49 | if Dict.has_key?(state, :sdp) do 50 | probe(state) 51 | end 52 | else 53 | IO.puts inspect(attributes) 54 | raise "got a response" 55 | end 56 | 57 | new_state(state) 58 | end 59 | end -------------------------------------------------------------------------------- /lib/exwebrtc/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.Supervisor do 2 | use Supervisor.Behaviour 3 | 4 | def start_link do 5 | :supervisor.start_link(__MODULE__, []) 6 | end 7 | 8 | def init([]) do 9 | children = [ 10 | # Define workers and child supervisors to be supervised 11 | # 4488 is in the script.js 12 | worker(Exwebrtc.STUNServer, [4488]), 13 | ] 14 | 15 | # See http://elixir-lang.org/docs/stable/Supervisor.Behaviour.html 16 | # for other strategies and supported options 17 | supervise(children, strategy: :one_for_one) 18 | end 19 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exwebrtc.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :exwebrtc, 6 | version: "0.0.1", 7 | elixir: "~> 0.13.1", 8 | deps: deps] 9 | end 10 | 11 | # Configuration for the OTP application 12 | # 13 | # Type `mix help compile.app` for more information 14 | def application do 15 | [ 16 | applications: [ 17 | :cowboy, 18 | :crypto, 19 | ], 20 | mod: { Exwebrtc, [] } 21 | ] 22 | end 23 | 24 | defp deps do 25 | [ 26 | { :cowboy, github: "extend/cowboy" }, 27 | { :exactor, "~> 0.3.2" }, 28 | { :hound, github: "HashNuke/hound" } 29 | ] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowboy": {:git, "git://github.com/extend/cowboy.git", "1ad3aae4d5459ee991805d16a3837e1da2ba96ff", []}, 2 | "cowlib": {:git, "git://github.com/extend/cowlib.git", "f58340a0044856bb508df03cfe94cf79308380a2", [ref: "0.6.1"]}, 3 | "ex_doc": {:git, "git://github.com/elixir-lang/ex_doc.git", "fc0f00d2ff3c5e20eaee519b937f6feb998aa0d0", []}, 4 | "exactor": {:package, "0.3.2"}, 5 | "hound": {:git, "git://github.com/HashNuke/hound.git", "52261f96013128b314cc5f6c99ddd2031602595e", []}, 6 | "ibrowse": {:git, "git://github.com/cmullaparthi/ibrowse.git", "e8ae353c16d4f0897abb9f80025b52925b974dd1", [tag: "v4.0.2"]}, 7 | "jsex": {:git, "git://github.com/talentdeficit/jsex.git", "03ad4ff0967331afd01464857d41e5e497ed198c", []}, 8 | "jsx": {:git, "git://github.com/talentdeficit/jsx.git", "507fa4c41db33c81e925ab53f4d789d234aaff2f", [tag: "v2.0.1"]}, 9 | "ranch": {:git, "git://github.com/extend/ranch.git", "5df1f222f94e08abdcab7084f5e13027143cc222", [ref: "0.9.0"]}} 10 | -------------------------------------------------------------------------------- /priv/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RTCDataChannel 6 | 7 | 8 | 9 |
10 | 11 |

RTCDataChannel

12 | 13 |
14 | 15 | 16 | 17 |
18 | 19 |
20 |
21 |

Send

22 | 23 |
24 |
25 |

Receive

26 | 27 |
28 |
29 | 30 |

View the console to see logging.

31 | 32 |

The RTCPeerConnection objects localPeerConnection and remotePeerConnection are in global scope, so you can inspect them in the console as well.

33 |

Code in this example used by kind permission of Vikas Marwaha.

34 |

For more information about PeerConnection, see Getting Started With WebRTC.

35 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /priv/static/main.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #77aaff; 3 | text-decoration: none; 4 | } 5 | 6 | a:hover { 7 | color: #88bbff; 8 | text-decoration: underline; 9 | } 10 | 11 | a#viewSource { 12 | display: block; 13 | margin: 1.3em 0 0 0; 14 | border-top: 1px solid #999; 15 | padding: 1em 0 0 0; 16 | } 17 | 18 | div#links a { 19 | display: block; 20 | line-height: 1.3em; 21 | margin: 0 0 1.5em 0; 22 | } 23 | 24 | @media screen and (min-width: 1000px) { 25 | /* hack! to detect non-touch devices */ 26 | div#links a { 27 | line-height: 0.8em; 28 | } 29 | } 30 | 31 | audio { 32 | max-width: 100%; 33 | } 34 | 35 | body { 36 | background: #666; 37 | font-family: Arial, sans-serif; 38 | margin: 0; 39 | padding: 1.5em; 40 | word-break: break-word; 41 | } 42 | 43 | button { 44 | margin: 0 0.5em 0 0; 45 | width: 5.7em; 46 | } 47 | 48 | button[disabled] { 49 | color: #aaa; 50 | } 51 | 52 | code { 53 | font-family: 'Courier New', monospace; 54 | letter-spacing: -0.1em; 55 | } 56 | 57 | div#container { 58 | background: #000; 59 | margin: 0 auto 0 auto; 60 | max-width: 40em; 61 | padding: 1em 1.5em 1.3em 1.5em; 62 | } 63 | 64 | div#links { 65 | padding: 0.5em 0 0 0; 66 | } 67 | 68 | h1 { 69 | border-bottom: 1px solid #aaa; 70 | color: white; 71 | font-family: Arial, sans-serif; 72 | margin: 0 0 0.8em 0; 73 | padding: 0 0 0.4em 0; 74 | } 75 | 76 | h2 { 77 | color: #ccc; 78 | font-family: Arial, sans-serif; 79 | margin: 1.8em 0 0.6em 0; 80 | } 81 | 82 | html { 83 | /* avoid annoying page width change 84 | when moving from the home page */ 85 | overflow-y: scroll; 86 | } 87 | 88 | img { 89 | border: none; 90 | max-width: 100%; 91 | } 92 | 93 | p { 94 | color: #eee; 95 | line-height: 1.6em; 96 | } 97 | 98 | p#data { 99 | border-top: 1px dotted #666; 100 | font-family: Courier New, monospace; 101 | line-height: 1.3em; 102 | max-height: 1000px; 103 | overflow-y: auto; 104 | padding: 1em 0 0 0; 105 | } 106 | 107 | p.borderBelow { 108 | border-bottom: 1px solid #aaa; 109 | padding: 0 0 20px 0; 110 | } 111 | 112 | video { 113 | background: #222; 114 | width: 100%; 115 | } 116 | 117 | @media screen and (min-width: 1000px) { 118 | video { 119 | } 120 | } 121 | 122 | @media screen and (max-width: 1000px) { 123 | video { 124 | } 125 | } 126 | 127 | 128 | div#buttons { 129 | margin: 0 0 1em 0; 130 | } 131 | div#receive { 132 | } 133 | div#send { 134 | float: left; 135 | margin: 0 3em 1em 0; 136 | } 137 | div#sendReceive { 138 | margin: 0 0 1em 0; 139 | } 140 | \h1 { 141 | margin: 0 0 1em 0; 142 | } 143 | h2 { 144 | margin: 0 0 0.5em 0; 145 | } 146 | textarea { 147 | color: #444; 148 | font-family: 'Courier New', monospace; 149 | font-size: 1em; 150 | height: 7.0em; 151 | padding: 0.5em; 152 | } 153 | -------------------------------------------------------------------------------- /priv/static/script.js: -------------------------------------------------------------------------------- 1 | var PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; 2 | var SessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription || window.webkitRTCSessionDescription; 3 | 4 | var serverChannel; 5 | 6 | var startButton = document.getElementById('startButton'); 7 | var sendButton = document.getElementById('sendButton'); 8 | var closeButton = document.getElementById('closeButton'); 9 | startButton.disabled = false; 10 | sendButton.disabled = true; 11 | closeButton.disabled = true; 12 | startButton.onclick = createConnection; 13 | sendButton.onclick = sendData; 14 | closeButton.onclick = closeDataChannels; 15 | 16 | function trace(text) { 17 | console.log.apply(console, arguments); 18 | //console.log((performance.now() / 1000).toFixed(3) + ": " + text); 19 | } 20 | 21 | var serverSDP = [ 22 | "v=0", 23 | "o=Mozilla-SIPUA-28.0 17836 0 IN IP4 71.63.48.107", 24 | "s=SIP Call", 25 | "t=0 0", 26 | "a=ice-ufrag:3081b21e", 27 | "a=ice-pwd:9b4424d9e8c5e253c0290d63328b55b3", 28 | "a=fingerprint:sha-256 53:CE:F0:CC:D5:09:EE:CD:A4:AE:31:22:09:EE:27:FE:2B:7D:E7:D4:F1:F6:3B:A5:1F:DB:69:30:19:49:57:1B", 29 | "m=application 4489 DTLS/SCTP 5000", 30 | "c=IN IP4 192.168.42.112", 31 | "a=sctpmap:5000 webrtc-datachannel 16", 32 | "a=setup:actpass", 33 | "a=candidate:0 1 UDP 2130379007 192.168.42.112 4488 typ host", 34 | "a=candidate:0 2 UDP 2130379006 192.168.42.112 4489 typ host" 35 | ].join('\r\n'); 36 | var serverOffer = {"type": "offer", "sdp": serverSDP}; 37 | 38 | function createConnection() { 39 | var servers = null; 40 | window.serverConnection = new PeerConnection(servers, {optional: [{RtpDataChannels: true}]}); 41 | trace('Created local peer connection object serverConnection'); 42 | 43 | try { 44 | // Reliable Data Channels not yet supported in Chrome 45 | serverChannel = serverConnection.createDataChannel("sendDataChannel", {reliable: false}); 46 | trace('Created send data channel'); 47 | } catch (e) { 48 | alert('Failed to create data channel. ' + 49 | 'You need Chrome M25 or later with RtpDataChannel enabled'); 50 | trace('createDataChannel() failed with exception: ' + e.message); 51 | } 52 | serverConnection.onicecandidate = gotServerCandidate; 53 | serverConnection.onsignalingstatechange = console.log; 54 | serverChannel.onmessage = handleMessage; 55 | serverChannel.onopen = handleServerChannelStateChange; 56 | serverChannel.onclose = handleServerChannelStateChange; 57 | 58 | serverConnection.setRemoteDescription(new SessionDescription(serverOffer), function() { 59 | serverConnection.createAnswer(createAnswerCallback, createAnswerErrback); 60 | }, function() { 61 | console.error("Error setting remote description", arguments); 62 | }); 63 | 64 | startButton.disabled = true; 65 | closeButton.disabled = false; 66 | } 67 | 68 | function createAnswerCallback(desc) { 69 | $.ajax('/answer_sdp', { 70 | data : JSON.stringify(desc), 71 | contentType : 'application/json', 72 | type : 'POST' 73 | }); 74 | console.log('createAnswerCallback result', desc.sdp); 75 | serverConnection.setLocalDescription(desc); 76 | } 77 | 78 | function createAnswerErrback() { 79 | console.error('createAnswerErrback', arguments); 80 | } 81 | 82 | function gotServerCandidate(event) { 83 | console.log('local ice callback', event); 84 | if (event.candidate) { 85 | trace('Local ICE candidate: \n' + event.candidate.candidate); 86 | } 87 | } 88 | 89 | function sendData() { 90 | var data = document.getElementById("dataChannelSend").value; 91 | serverChannel.send(data); 92 | trace('Sent data: ' + data); 93 | } 94 | 95 | function closeDataChannels() { 96 | trace('Closing data channels'); 97 | serverChannel.close(); 98 | trace('Closed data channel with label: ' + serverChannel.label); 99 | //trace('Closed data channel with label: ' + receiveChannel.label); 100 | serverConnection.close(); 101 | serverConnection = null; 102 | trace('Closed peer connections'); 103 | startButton.disabled = false; 104 | sendButton.disabled = true; 105 | closeButton.disabled = true; 106 | dataChannelSend.value = ""; 107 | dataChannelReceive.value = ""; 108 | dataChannelSend.disabled = true; 109 | dataChannelSend.placeholder = "Press Start, enter some text, then press Send."; 110 | } 111 | 112 | function handleMessage(event) { 113 | trace('Received message: ' + event.data); 114 | document.getElementById("dataChannelReceive").value = event.data; 115 | } 116 | 117 | function handleServerChannelStateChange() { 118 | var readyState = serverChannel.readyState; 119 | trace('Send channel state is: ' + readyState); 120 | if (readyState == "open") { 121 | dataChannelSend.disabled = false; 122 | dataChannelSend.focus(); 123 | dataChannelSend.placeholder = ""; 124 | sendButton.disabled = false; 125 | closeButton.disabled = false; 126 | } else { 127 | dataChannelSend.disabled = true; 128 | sendButton.disabled = true; 129 | closeButton.disabled = true; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/exwebrtc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExwebrtcTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule IntegrationTest do 2 | use ExUnit.Case 3 | use Hound.Helpers 4 | 5 | hound_session 6 | 7 | # test "make webrtc connection", meta do 8 | # navigate_to("http://localhost:8080/") 9 | 10 | # find_element(:id, "startButton") 11 | # |> click() 12 | 13 | # assert page_title() == "Thank you" 14 | # end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /test/sdp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SDPTest do 2 | use ExUnit.Case 3 | alias Exwebrtc.SDP, as: SDP 4 | 5 | test "password" do 6 | assert "e103956099236daf77be8198a163137e" == SDP.password(sample_sdp) 7 | end 8 | 9 | def sample_sdp do 10 | "v=0\r\no=Mozilla-SIPUA-29.0 24488 0 IN IP4 0.0.0.0\r\ns=SIP Call\r\nt=0 0\r\na=ice-ufrag:2d2c4961\r\na=ice-pwd:e103956099236daf77be8198a163137e\r\na=fingerprint:sha-256 23:D5:3A:C4:2F:4D:89:36:0B:98:56:BC:5F:A0:C3:E8:71:D1:5F:FD:EF:06:FB:63:28:8B:08:00:F2:C4:57:10\r\nm=application 61421 DTLS/SCTP 5000 \r\nc=IN IP4 71.63.48.107\r\na=sctpmap:5000 webrtc-datachannel 16\r\na=setup:active\r\na=candidate:0 1 UDP 2130379007 192.168.42.112 61421 typ host\r\na=candidate:1 1 UDP 1694236671 71.63.48.107 61421 typ srflx raddr 192.168.42.112 rport 61421\r\n" 11 | end 12 | end -------------------------------------------------------------------------------- /test/stun_test.exs: -------------------------------------------------------------------------------- 1 | defmodule STUNTest do 2 | use ExUnit.Case 3 | alias Exwebrtc.STUN, as: STUN 4 | 5 | test "string_xor" do 6 | assert <<235, 250>> == STUN.string_xor(<<51944 :: size(16)>>, <<33, 18, 164, 66>>) 7 | end 8 | 9 | test "ip_address_to_binary" do 10 | assert <<192, 168, 42, 8>> == STUN.ip_address_to_binary("192.168.42.8") 11 | end 12 | 13 | test "encode_xor_mapped_address" do 14 | # from the captured request 15 | target = <<0x00, 0x20, 0x00, 0x08, 0x00, 0x01, 0xeb, 0xfa, 0xe1, 0xba, 0x8e, 0x4a>> 16 | results = STUN.encode_attribute(:xor_mapped_address, {"192.168.42.8", 51944}) 17 | assert target == iodata_to_binary(results) 18 | end 19 | 20 | test "encode use_candidate" do 21 | target = <<0, 37, 0, 0>> 22 | results = STUN.encode_attribute(:use_candidate, nil) 23 | assert target == iodata_to_binary(results) 24 | end 25 | 26 | test "encode ice_controlling" do 27 | target = <<128, 42, 0, 8, 0, 0, 0, 0, 0, 0, 4, 87>> 28 | results = STUN.encode_attribute(:ice_controlling, 1111) 29 | assert target == iodata_to_binary(results) 30 | end 31 | 32 | test "parse captured request" do 33 | {:ok, ret} = STUN.parse(stun_request_1, fn(_r) -> "755f33f22509329a49ab3d6420e947e9" end) 34 | assert :request == ret[:request_type] 35 | assert <<33, 18, 164, 66, 124, 83, 243, 18, 121, 83, 109, 153, 192, 13, 20, 77>> == ret[:transaction_id] 36 | assert "d7de9017:b52d0601" == ret[:username] 37 | assert 1853817087 == ret[:priority] 38 | assert 1139902001367096328 == ret[:ice_controlled] 39 | end 40 | 41 | test "parse captured request with bad fingerprint" do 42 | stun_request_with_bad_fingerprint = binary_part(stun_request_1, 0, iodata_size(stun_request_1) - 4) <> <<0, 0, 0, 0>> 43 | {:error, "bad fingerprint"} = STUN.parse(stun_request_with_bad_fingerprint, fn(_r) -> "755f33f22509329a49ab3d6420e947e9" end) 44 | end 45 | 46 | test "parse captured request with wrong password for message integrity" do 47 | {:error, "invalid message integrity"} = STUN.parse(stun_request_1, fn(_r) -> "foo" end) 48 | end 49 | 50 | test "parse captured response" do 51 | {:ok, response} = STUN.parse(stun_response_1, fn(_r) -> "755f33f22509329a49ab3d6420e947e9" end) 52 | assert {"192.168.42.8", 51944} = response[:mapped_address] 53 | end 54 | 55 | test "build request" do 56 | {:ok, packet} = STUN.build_request( 57 | transaction_id: << 33, 18, 164, 66, 124, 83, 243, 18, 121, 83, 109, 153, 192, 13, 20, 77 >>, 58 | username: "d7de9017:b52d0601", 59 | priority: 1853817087, 60 | ice_controlled: 1139902001367096328, 61 | message_integrity_key: "755f33f22509329a49ab3d6420e947e9" 62 | ) 63 | assert stun_request_1 == iodata_to_binary(packet) 64 | end 65 | 66 | test "build bind success reply" do 67 | {:ok, packet} = STUN.build_reply( 68 | transaction_id: << 33, 18, 164, 66, 124, 83, 243, 18, 121, 83, 109, 153, 192, 13, 20, 77 >>, 69 | message_integrity_key: "755f33f22509329a49ab3d6420e947e9", 70 | mapped_address: {"192.168.42.8", 51944} 71 | ) 72 | assert stun_response_1 == iodata_to_binary(packet) 73 | end 74 | 75 | test "build request with generated transaction id" do 76 | {:ok, packet} = STUN.build_request( 77 | ice_controlling: 6263569403430582672, 78 | priority: 1861943551, 79 | use_candidate: nil, 80 | username: "a00970de:3081b21e", 81 | message_integrity_key: "cfe7c4bd1e6dcae0b325c8e5ef21e30f", 82 | ) 83 | assert {:ok, _attribs} = STUN.parse(iodata_to_binary(packet), fn(_r) -> "cfe7c4bd1e6dcae0b325c8e5ef21e30f" end) 84 | end 85 | 86 | test "build request 2" do 87 | {:ok, packet} = STUN.build_request( 88 | transaction_id: << 33, 18, 164, 66, 81, 233, 59, 241, 122, 85, 197, 62, 127, 136, 64, 65 >>, 89 | ice_controlling: 6263569403430582672, 90 | priority: 1861943551, 91 | use_candidate: nil, 92 | username: "a00970de:3081b21e", 93 | message_integrity_key: "cfe7c4bd1e6dcae0b325c8e5ef21e30f", 94 | ) 95 | assert stun_request_2 == iodata_to_binary(packet) 96 | assert {:ok, _attribs} = STUN.parse(iodata_to_binary(packet), fn(_r) -> "cfe7c4bd1e6dcae0b325c8e5ef21e30f" end) 97 | end 98 | 99 | def stun_request_2 do 100 | << 0, 1, 0, 80, 33, 18, 164, 66, 81, 233, 59, 241, 122, 85, 197, 62, 127, 136, 64, 65, 0, 6, 0, 17, 97, 48, 48, 57, 55, 48, 100, 101, 58, 51, 48, 56, 49, 98, 50, 49, 101, 0, 0, 0, 0, 37, 0, 0, 0, 36, 0, 4, 110, 251, 0, 255, 128, 42, 0, 8, 86, 236, 171, 47, 197, 124, 57, 144, 0, 8, 0, 20, 150, 46, 42, 92, 119, 22, 198, 184, 84, 30, 79, 234, 21, 179, 39, 27, 107, 211, 227, 21, 128, 40, 0, 4, 109, 148, 93, 51 >> 101 | end 102 | 103 | def stun_request_1 do 104 | # Captured from Wireshark with two Firefox 28 browser talking to each other 105 | << 106 | 0x00, 0x01, 0x00, 0x4c, 0x21, 0x12, 0xa4, 0x42, 107 | 0x7c, 0x53, 0xf3, 0x12, 0x79, 0x53, 0x6d, 0x99, 108 | 0xc0, 0x0d, 0x14, 0x4d, 0x00, 0x06, 0x00, 0x11, 109 | 0x64, 0x37, 0x64, 0x65, 0x39, 0x30, 0x31, 0x37, 110 | 0x3a, 0x62, 0x35, 0x32, 0x64, 0x30, 0x36, 0x30, 111 | 0x31, 0x00, 0x00, 0x00, 0x00, 0x24, 0x00, 0x04, 112 | 0x6e, 0x7f, 0x00, 0xff, 0x80, 0x29, 0x00, 0x08, 113 | 0x0f, 0xd1, 0xbe, 0xd4, 0xae, 0x3e, 0x1c, 0x08, 114 | 0x00, 0x08, 0x00, 0x14, 0xae, 0xc2, 0xb0, 0x40, 115 | 0xea, 0x55, 0x75, 0x6b, 0xfd, 0x61, 0xab, 0x4a, 116 | 0xf8, 0x4d, 0x1e, 0x7c, 0xca, 0x36, 0x70, 0xad, 117 | 0x80, 0x28, 0x00, 0x04, 0x7a, 0xc7, 0x0f, 0xad 118 | >> 119 | end 120 | 121 | def stun_response_1 do 122 | # Captured from Wireshark with two Firefox 28 browser talking to each other 123 | << 124 | 0x01, 0x01, 0x00, 0x2c, 0x21, 0x12, 0xa4, 0x42, 125 | 0x7c, 0x53, 0xf3, 0x12, 0x79, 0x53, 0x6d, 0x99, 126 | 0xc0, 0x0d, 0x14, 0x4d, 0x00, 0x20, 0x00, 0x08, 127 | 0x00, 0x01, 0xeb, 0xfa, 0xe1, 0xba, 0x8e, 0x4a, 128 | 0x00, 0x08, 0x00, 0x14, 0x30, 0x35, 0xe6, 0x1e, 129 | 0xb7, 0xab, 0x88, 0x47, 0x63, 0xd3, 0x83, 0x4f, 130 | 0x76, 0xb1, 0x8a, 0x02, 0x08, 0x66, 0x93, 0x25, 131 | 0x80, 0x28, 0x00, 0x04, 0x4f, 0xf2, 0xf9, 0xa1 132 | >> 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | Hound.start [browser: "firefox"] 3 | --------------------------------------------------------------------------------