├── .gitignore ├── LICENSE ├── Makefile ├── autobahn └── config │ └── config.json ├── config ├── ab.exs ├── config.exs ├── dev.exs └── test.exs ├── lib ├── acceptor.ex ├── exws.ex ├── handler.ex ├── handshake.ex ├── mask.ex ├── reader.ex ├── server.ex ├── supervisor.ex └── writer.ex ├── mix.exs ├── mix.lock ├── readme.md └── test ├── integration_test.exs ├── support ├── ab_client.ex ├── gen_tcp_fake.ex ├── tests.ex └── ws.ex ├── test_helper.exs └── ws ├── frame_test.exs └── handshake_test.exs /.gitignore: -------------------------------------------------------------------------------- 1 | log/ 2 | deps/ 3 | doc/ 4 | _build/ 5 | autobahn/reports 6 | /*.ez 7 | .DS_Store 8 | erl_crash.dump 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Karl Seguin. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | F= 2 | PIDFILE=/tmp/ws.pid 3 | 4 | .PHONY: t 5 | t: 6 | mix test ${F} 7 | 8 | .PHONY: s 9 | s: 10 | env MIX_ENV=ab mix run -e 'ExWs.Server.start_for_tests()' 11 | 12 | .PHONY: ab 13 | ab: 14 | env MIX_ENV=ab mix compile 15 | env MIX_ENV=ab mix run -e 'ExWs.Server.start_for_tests()' & echo $$! > $(PIDFILE) 16 | sleep 1 # give chance for socket to listen 17 | 18 | docker run --rm \ 19 | --net="host" \ 20 | -v "$(PWD)/autobahn/config:/config" \ 21 | -v "$(PWD)/autobahn/reports:/reports" \ 22 | --name fuzzingclient \ 23 | --platform linux/amd64 \ 24 | crossbario/autobahn-testsuite \ 25 | /opt/pypy/bin/wstest --mode fuzzingclient --spec /config/config.json; 26 | kill $$(cat $(PIDFILE)) || true; 27 | rm $(PIDFILE) 28 | @if grep FAILED autobahn/reports/index.json*; \ 29 | then exit 1; \ 30 | else exit 0; \ 31 | fi 32 | 33 | -------------------------------------------------------------------------------- /autobahn/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outdir": "./reports/", 3 | "servers": [{"url": "ws://host.docker.internal:4545"}], 4 | "cases": ["*"], 5 | "exclude-cases": [], 6 | "exclude-agent-cases": {} 7 | } 8 | -------------------------------------------------------------------------------- /config/ab.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :exws, 4 | port: 4545, 5 | handler: ExWs.Tests.ABHandler 6 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "test.exs" 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :exws, 4 | port: 4545, 5 | handler: ExWs.Tests.Integration.Handler 6 | 7 | if System.get_env("AB") == "1" do 8 | import_config "ab.exs" 9 | end 10 | -------------------------------------------------------------------------------- /lib/acceptor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Acceptor do 2 | use Task, restart: :transient 3 | require Logger 4 | 5 | def start_link(opts) do 6 | Task.start_link(__MODULE__, :run, opts) 7 | end 8 | 9 | def run(opts) do 10 | accept_loop(opts[:socket], opts[:handler]) 11 | end 12 | 13 | defp accept_loop(listen_socket, handler) do 14 | case :gen_tcp.accept(listen_socket) do 15 | {:ok, client_socket} -> 16 | opts = {client_socket, ExWs.Reader.new()} 17 | case GenServer.start(handler, opts) do 18 | {:ok, pid} -> 19 | :gen_tcp.controlling_process(client_socket, pid) 20 | GenServer.cast(pid, :ready) 21 | err -> Logger.error("handler start: #{inspect(err)}") 22 | end 23 | {:error, err} -> Logger.error("accept: #{inspect(err)}") 24 | end 25 | accept_loop(listen_socket, handler) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/exws.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs do 2 | alias ExWs.Writer 3 | 4 | defdelegate txt(data), to: Writer 5 | defdelegate bin(data), to: Writer 6 | defdelegate to_binary(frame), to: Writer 7 | 8 | def invalid_handshake(err) do 9 | ExWs.Handshake.Errors.build(400, err) 10 | end 11 | 12 | def write(socket, {:framed, data}) do 13 | :gen_tcp.send(socket, data) 14 | end 15 | 16 | def write(socket, data) do 17 | write(socket, Writer.txt(data)) 18 | end 19 | 20 | def ping(socket) do 21 | :gen_tcp.send(socket, Writer.ping()) 22 | end 23 | 24 | def pong(socket, data) do 25 | :gen_tcp.send(socket, Writer.pong(data)) 26 | end 27 | 28 | def close(socket, {:framed, data}) do 29 | :inet.setopts(socket, send_timeout: 1_000) 30 | :gen_tcp.send(socket, data) 31 | :gen_tcp.close(socket) 32 | :closed 33 | end 34 | 35 | def close(socket, message, code) do 36 | close(socket, Writer.close(message, code)) 37 | end 38 | 39 | def close(socket) do 40 | close(socket, Writer.close(nil)) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Handler do 2 | defmacro __using__(_opts) do 3 | quote location: :keep do 4 | use GenServer 5 | 6 | alias ExWs.{Handshake, Writer} 7 | @compile {:inline, get_socket: 0, get_reader: 0, put_reader: 1} 8 | 9 | @ws_empty_close <<>> |> Writer.close(1000) |> Writer.to_binary() 10 | @ws_normal_close "Normal Closure" |> Writer.close(1000) |> Writer.to_binary() 11 | @ws_invalid_close <<>> |> Writer.close(1002) |> Writer.to_binary() 12 | 13 | def init({socket, reader}) do 14 | put_socket(socket) 15 | put_reader(reader) 16 | :inet.setopts(socket, send_timeout_close: true, send_timeout: 10_000, exit_on_close: true) 17 | {:ok, init()} 18 | end 19 | 20 | defp init(), do: nil 21 | defp handshake(_path, _header, state), do: {:ok, state} 22 | defp closed(_reason, state) do 23 | shutdown() 24 | state 25 | end 26 | 27 | # Most handlers probably won't care about :bin vs :txt message 28 | # ops, so by default we discard it. If a handler does care about 29 | # the specific op, it can implement its own message/3 30 | defp message(_op, data, state), do: message(data, state) 31 | defoverridable [init: 0, handshake: 3, closed: 2, message: 3] 32 | 33 | def handle_cast(:ready, state) do 34 | socket = get_socket() 35 | with {:ok, path, headers, socket} <- Handshake.read(socket), 36 | {:ok, state} <- handshake(path, headers, state) 37 | do 38 | :inet.setopts(socket, packet: :raw, active: true) 39 | Handshake.accept(socket, headers) 40 | {:noreply, state} 41 | else 42 | :closed -> {:noreply, closed(:reject_handshake, state)} 43 | {:close, error} -> 44 | Handshake.reject(socket, error) 45 | {:noreply, closed(:reject_handshake, state)} 46 | end 47 | end 48 | 49 | def handle_cast(:shutdown, state), do: {:stop, :normal, state} 50 | 51 | def handle_info({:tcp, socket, data}, state) do 52 | message = 53 | case ExWs.Reader.received(data, get_reader()) do 54 | {:ok, reader} -> put_reader(reader); :ok 55 | {:ok, messages, reader} -> put_reader(reader); {:ok, messages} 56 | end 57 | 58 | state = case message do 59 | :ok -> state 60 | {:ok, {op, message}} -> message_received(op, message, state) 61 | {:ok, messages} -> 62 | Enum.reduce(messages, state, fn {op, message}, state -> 63 | message_received(op, message, state) 64 | end) 65 | {:close, reason} -> closed(reason, state) 66 | end 67 | {:noreply, state} 68 | end 69 | 70 | def handle_info({:tcp_closed, _socket}, state) do 71 | {:noreply, closed(:tcp_closed, state)} 72 | end 73 | 74 | def handle_info({:tcp_error, socket, _reason}, state) do 75 | :gen_tcp.close(socket) 76 | {:noreply, closed(:tcp_error, state)} 77 | end 78 | 79 | defp message_received(op, data, state) when op in [:bin, :txt] do 80 | message(op, data, state) 81 | end 82 | 83 | defp message_received(:close, data, state) do 84 | handle_close(data, :client, state) 85 | end 86 | 87 | defp message_received(:ping, data, state) do 88 | ExWs.pong(get_socket(), data) 89 | state 90 | end 91 | 92 | defp message_received(:pong, _data, state), do: state 93 | 94 | # This is typically called when our Reader gets an invalid message. 95 | # For example, if we get an invalid op code, the close message is 96 | # framed at compile-time (for efficiency) and we end up here 97 | defp handle_close({:framed, data} = frame, _reason, state) do 98 | ExWs.close(get_socket(), frame) 99 | closed(:protocol, state) 100 | end 101 | 102 | defp handle_close(data, reason, state) do 103 | data = case :erlang.iolist_to_binary(data || "") do 104 | <> -> 105 | cond do 106 | code == 1001 -> @ws_normal_close 107 | code < 1000 || code in [1004, 1005, 1006] || (code > 1013 && code < 3000) -> @ws_invalid_close 108 | String.valid?(message) -> Writer.close_echo(data) 109 | true -> @ws_invalid_close 110 | end 111 | <<>> -> @ws_normal_close 112 | _ -> @ws_invalid_close 113 | end 114 | ExWs.close(get_socket(), data) # echo this back to the client, as per the spec 115 | closed(reason, state) 116 | end 117 | 118 | defp ping(), do: ExWs.ping(get_socket()) 119 | defp close(), do: ExWs.close(get_socket()) 120 | defp close(message, code), do: ExWs.close(get_socket(), message, code) 121 | defp write(data), do: ExWs.write(get_socket(), data) 122 | defoverridable [write: 1] # incase you want the default to be bin 123 | 124 | defp shutdown() do 125 | :gen_tcp.close(get_socket()) 126 | GenServer.cast(self(), :shutdown) 127 | end 128 | 129 | defp get_socket(), do: Process.get(:socket) 130 | defp put_socket(socket), do: Process.put(:socket, socket) 131 | 132 | defp get_reader(), do: Process.get(:reader) 133 | defp put_reader(reader), do: Process.put(:reader, reader) 134 | 135 | defp pid_socket(), do: {self(), Process.get(:socket)} 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/handshake.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Handshake.Errors do 2 | def build(code, message) do 3 | data = [ 4 | "HTTP/1.1 ", to_string(code), " ", phrase(code), "\r\n", 5 | "Error: ", message, "\r\n", 6 | "Content-Length: 0\r\n", 7 | "\r\n" 8 | ] 9 | {:invalid, :erlang.iolist_to_binary(data)} 10 | end 11 | 12 | defp phrase(400), do: "Bad Request" 13 | defp phrase(_), do: "Server Error" 14 | end 15 | 16 | defmodule ExWs.Handshake do 17 | require Logger 18 | 19 | alias __MODULE__.Errors 20 | 21 | if Mix.env == :test && System.get_env("AB") != "1" do 22 | @inet ExWs.GenTcpFake 23 | @gen_tcp ExWs.GenTcpFake 24 | else 25 | @inet :inet 26 | @gen_tcp :gen_tcp 27 | end 28 | 29 | @invalid_request_line Errors.build(400, "request_line") 30 | @invalid_path Errors.build(400, "path") 31 | @invalid_method Errors.build(400, "method") 32 | @invalid_proto Errors.build(400, "protocol") 33 | @invalid_headers Errors.build(400, "headers") 34 | 35 | @invalid_key Errors.build(400, "key") 36 | @invalid_host Errors.build(400, "host") 37 | @invalid_version Errors.build(400, "version") 38 | @invalid_upgrade Errors.build(400, "upgrade") 39 | @invalid_connection Errors.build(400, "connection") 40 | 41 | if Mix.env == :prod do 42 | @timeout 5000 43 | else 44 | @timeout 100 45 | end 46 | 47 | def read(socket) do 48 | @inet.setopts(socket, packet: :line) 49 | with {:ok, request_line} <- read_request_line(socket), 50 | {:ok, path} <- verify_request_line(request_line), 51 | {:ok, headers} <- read_headers(socket, %{}), 52 | :ok <- validate_headers(headers) 53 | do 54 | {:ok, path, headers, socket} 55 | else 56 | err -> close(socket, err); :closed 57 | end 58 | end 59 | 60 | defp read_request_line(socket) do 61 | case read_line(socket) do 62 | {:ok, line} -> {:ok, line} 63 | _ -> @invalid_request_line 64 | end 65 | end 66 | 67 | defp verify_request_line(line) do 68 | with {:ok, line} <- ensure_method(line), 69 | {:ok, path, line} <- extract_path(trim_leading(line)), 70 | :ok <- ensure_protocol(trim_leading(line)) 71 | do 72 | {:ok, path} 73 | end 74 | end 75 | 76 | defp ensure_method(<>) do 77 | case string_compare(method, "get") do 78 | true -> {:ok, line} 79 | false -> @invalid_method 80 | end 81 | end 82 | 83 | defp ensure_method(_line) do 84 | @invalid_method 85 | end 86 | 87 | defp extract_path(line) do 88 | case :binary.split(line, " ") do 89 | [path, line] -> {:ok, path, line} 90 | _ -> @invalid_path 91 | end 92 | end 93 | 94 | defp ensure_protocol(line) do 95 | case string_compare(line, "http/1.1") do 96 | true -> :ok 97 | false -> @invalid_proto 98 | end 99 | end 100 | 101 | defp read_headers(socket, headers) do 102 | with {:ok, line} when line != "" <- read_line(socket), 103 | [name, value] <- :binary.split(line, ":") 104 | do 105 | read_headers(socket, set_header(headers, header_downcase(name, []), value)) 106 | else 107 | {:ok, ""} -> {:ok, headers} # An empty readline means we're done 108 | {:error, _} = err -> err 109 | _ -> @invalid_headers 110 | end 111 | end 112 | 113 | for {key, name} <- [host: "host", connection: "connection", upgrade: "upgrade", key: "sec-websocket-key", version: "sec-websocket-version"] do 114 | defp set_header(headers, unquote(name), value) do 115 | Map.put(headers, unquote(key), String.trim(value)) 116 | end 117 | end 118 | 119 | defp set_header(headers, _key, _value), do: headers 120 | 121 | defp validate_headers(headers) do 122 | key = headers[:key] 123 | host = headers[:host] 124 | with true <- key not in [nil, ""] || @invalid_key, 125 | true <- host not in [nil, ""] || @invalid_host, 126 | true <- headers[:version] == "13" || @invalid_version, 127 | true <- header_has_value(headers[:upgrade], "websocket") || @invalid_upgrade, 128 | true <- header_has_value(headers[:connection], "upgrade") || @invalid_connection 129 | do 130 | :ok 131 | end 132 | end 133 | 134 | def accept(socket, headers) do 135 | key = headers[:key] 136 | accept_key = :sha 137 | |> :crypto.hash([key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"]) 138 | |> Base.encode64() 139 | 140 | data = [ 141 | "HTTP/1.1 101 Switching Protocols\r\n", 142 | "Upgrade: websocket\r\n", 143 | "Connection: Upgrade\r\n", 144 | "Sec-WebSocket-Accept: ", accept_key, "\r\n", 145 | "\r\n" 146 | ] 147 | 148 | @inet.setopts(socket, send_timeout: @timeout) 149 | @gen_tcp.send(socket, data) 150 | end 151 | 152 | def reject(socket, err), do: close(socket, err) 153 | 154 | defp read_line(socket) do 155 | case @gen_tcp.recv(socket, 0, @timeout) do 156 | {:ok, data} -> 157 | # strip out the trailing \r\n 158 | length = byte_size(data) - 2 159 | <> = data 160 | {:ok, data} 161 | err -> err 162 | end 163 | end 164 | 165 | defp close(socket, {:invalid, message}) do 166 | @inet.setopts(socket, send_timeout: @timeout) 167 | @gen_tcp.send(socket, message) 168 | @gen_tcp.close(socket) 169 | end 170 | 171 | # Probably an error from :gen_tcp 172 | defp close(socket, err) do 173 | Logger.error("handshake: #{inspect(err)}") 174 | @gen_tcp.close(socket) 175 | end 176 | 177 | defp trim_leading(<<" ", data::binary>>), do: trim_leading(data) 178 | defp trim_leading(data), do: data 179 | 180 | defp header_has_value(actual, expected) do 181 | header_has_value(actual, expected, expected) 182 | end 183 | 184 | defp header_has_value(<<>>, <<>>, _expected), do: true 185 | defp header_has_value(<<",", _::binary>>, <<>>, _expected), do: true 186 | 187 | defp header_has_value(<<" ", rest::binary>>, <<>>, expected) do 188 | header_has_value(rest, <<>>, expected) 189 | end 190 | 191 | defp header_has_value(<>, <>, expected) do 192 | case i == t || i + 32 == t do 193 | true -> header_has_value(input, target, expected) 194 | false -> 195 | case :binary.split(input, ",") do 196 | [_, next] -> header_has_value(trim_leading(next), expected, expected) 197 | _ -> false 198 | end 199 | end 200 | end 201 | 202 | defp header_has_value(<<_::binary>>, <<>>, _expected), do: false 203 | defp header_has_value(<<>>, <<_::binary>>, _expected), do: false 204 | defp header_has_value(nil, _, _expected), do: false 205 | defp string_compare(<>, <>) do 206 | case i == t || i + 32 == t do 207 | false -> false 208 | true -> string_compare(input, target) 209 | end 210 | end 211 | 212 | defp string_compare(<<>>, <<>>), do: true 213 | defp string_compare(<<" ", input::binary>>, <<>>), do: string_compare(input, <<>>) 214 | defp string_compare(<<_::binary>>, <<>>), do: false 215 | defp string_compare(<<>>, <<_::binary>>), do: false 216 | 217 | # The HTML headers that we care about have very few legal values 218 | defp header_downcase(<<>>, acc) do 219 | acc |> Enum.reverse() |> :erlang.list_to_binary() 220 | end 221 | 222 | defp header_downcase(<>, acc) do 223 | c = case c >= ?A && c <= ?Z do 224 | true -> c + 32 225 | false -> c 226 | end 227 | header_downcase(input, [c | acc]) 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/mask.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Mask do 2 | import Bitwise, only: [bxor: 2] 3 | 4 | def apply(mask, data) do 5 | l = byte_size(data) 6 | mask = cond do 7 | l < 5 -> mask 8 | l < 9 -> <> 9 | l < 13 -> <> 10 | l < 17 -> <> 11 | l < 21 -> <> 12 | l < 25 -> <> 13 | l < 29 -> <> 14 | true -> <> 15 | end 16 | unmask(mask, data, []) 17 | end 18 | 19 | defp unmask(_, <<>>, unmasked), do: unmasked 20 | 21 | defp unmask(<> = m, <>, unmasked) do 22 | unmask(m, data, [unmasked, <>]) 23 | end 24 | 25 | for i <- (31..1//-1) do 26 | size = 8 * i 27 | defp unmask(<>, <>, unmasked) do 28 | [unmasked, <>] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/reader.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Reader do 2 | import Bitwise, only: [band: 2] 3 | 4 | alias __MODULE__ 5 | alias ExWs.{Writer, Mask} 6 | 7 | @compile {:inline, atomize_op: 1} 8 | 9 | @error_invalid_op "invalid op" |> Writer.close(1002) |> Writer.to_binary() 10 | @error_control_len "large control" |> Writer.close(1002) |> Writer.to_binary() 11 | @error_control_fin "non-fin control" |> Writer.close(1002) |> Writer.to_binary() 12 | @error_preamble_unmasked "unmasked frame" |> Writer.close(1002) |> Writer.to_binary() 13 | @error_invalid_continuation "invalid continuation" |> Writer.close(1002) |> Writer.to_binary() 14 | 15 | @enforce_keys [:op, :fin, :len, :mask, :data, :chain] 16 | defstruct @enforce_keys 17 | 18 | defguard is_control(op) when op in [:close, :ping, :pong] 19 | 20 | def new() do 21 | %Reader{op: nil, fin: nil, len: nil, mask: {4, <<>>}, data: nil, chain: nil} 22 | end 23 | 24 | def received(data, frame), do: parse(data, frame, []) 25 | 26 | defp parse(<<>>, frame, []), do: {:ok, frame} 27 | defp parse(<<>>, frame, acc), do: {:ok, acc, frame} 28 | defp parse(<>, %{op: nil} = frame, acc) do 29 | {op, fin} = case band(b1, 128) == 128 do 30 | true -> {b1 - 128, true} 31 | false -> {b1, false} 32 | end 33 | 34 | res = with {:ok, op} <- atomize_op(op) do 35 | has_chain? = frame.chain != nil 36 | is_control? = is_control(op) 37 | cond do 38 | op == :cont && !has_chain? -> {:close, @error_invalid_continuation} 39 | op != :cont && has_chain? && not is_control? -> {:close, @error_invalid_continuation} 40 | is_control? && !fin -> {:close, @error_control_fin} 41 | true -> parse(data, %Reader{frame | op: op, fin: fin}, acc) 42 | end 43 | end 44 | 45 | case {res, acc} do 46 | {{:close, _} = close, []} -> {:ok, close, frame} 47 | {{:close, _} = close, acc} -> {:ok, Enum.reverse([close | acc]), frame} 48 | {ok, _} -> ok 49 | end 50 | end 51 | 52 | defp parse(<>, %{len: nil} = frame, acc) do 53 | cond do 54 | masked == 0 -> {:close, @error_preamble_unmasked} 55 | len > 125 && is_control(frame.op) -> {:close, @error_control_len} 56 | len == 127 -> parse(data, %Reader{frame | len: {8, <<>>}}, acc) 57 | len == 126 -> parse(data, %Reader{frame | len: {2, <<>>}}, acc) 58 | len == 0 -> parse(data, %Reader{frame | len: 0, data: <<>>}, acc) 59 | true -> parse(data, %Reader{frame | len: len, data: {len, []}}, acc) 60 | end 61 | end 62 | 63 | defp parse(data, %{len: {missing, known}} = frame, acc) do 64 | case data do 65 | <> -> 66 | len = known <> len 67 | len = case byte_size(len) do 68 | 2 -> <> = len; len 69 | 8 -> <> = len; len 70 | end 71 | parse(data, %Reader{frame | len: len, data: {len, []}}, acc) 72 | _ -> {:ok, %Reader{frame | len: {missing - byte_size(data), known <> data}}} 73 | end 74 | end 75 | 76 | defp parse(data, %{mask: {missing, known}} = frame, acc) do 77 | case data do 78 | <> -> 79 | frame = %Reader{frame | mask: known <> mask} 80 | case frame.len == 0 do 81 | true -> finalize_frame(data, frame, acc) 82 | false -> parse(data, frame, acc) 83 | end 84 | _ -> {:ok, %Reader{frame | mask: {missing - byte_size(data), known <> data}}} 85 | end 86 | end 87 | 88 | defp parse(data, %{data: {missing, known}} = frame, acc) do 89 | case data do 90 | <> -> finalize_frame(extra, %Reader{frame | data: [known, data]}, acc) 91 | _ -> {:ok, %Reader{frame | data: {missing - byte_size(data), [known, data]}}} 92 | end 93 | end 94 | 95 | defp finalize_frame(extra, frame, acc) do 96 | data = Mask.apply(frame.mask, :erlang.iolist_to_binary(frame.data)) 97 | {next_frame, acc} = continue(frame, data, acc) 98 | case {extra, acc} do 99 | {<<>>, []} -> {:ok, next_frame} 100 | {<<>>, acc} -> {:ok, Enum.reverse(acc), next_frame} 101 | {_, _} -> parse(extra, next_frame, acc) 102 | end 103 | end 104 | 105 | defp continue(%{fin: true, chain: nil} = frame, unmasked, acc) do 106 | acc = [{frame.op, unmasked} | acc] 107 | {new(), acc} 108 | end 109 | 110 | defp continue(%{fin: true, op: :cont, chain: {op, data}}, unmasked, acc) do 111 | acc = [{op, [data, unmasked]} | acc] 112 | {new(), acc} 113 | end 114 | 115 | defp continue(%{fin: true} = frame, unmasked, acc) do 116 | acc = [{frame.op, unmasked} | acc] 117 | {%Reader{new() | chain: frame.chain}, acc} 118 | end 119 | 120 | defp continue(%{fin: false} = frame, unmasked, acc) do 121 | chain = case frame.chain do 122 | nil -> {frame.op, [unmasked]} 123 | {op, data} -> {op, [data, unmasked]} 124 | end 125 | {%Reader{new() | chain: chain}, acc} 126 | end 127 | 128 | for {value, op} <- [cont: 0, txt: 1, bin: 2, close: 8, ping: 9, pong: 10] do 129 | defp atomize_op(unquote(op)), do: {:ok, unquote(value)} 130 | end 131 | defp atomize_op(_op), do: {:close, @error_invalid_op} 132 | end 133 | -------------------------------------------------------------------------------- /lib/server.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Server do 2 | use Supervisor 3 | 4 | if Mix.env in [:ab, :test] do 5 | def start_for_tests() do 6 | {:ok, _pid} = start_link(Application.get_all_env(:exws)) 7 | :timer.sleep(:infinity) 8 | end 9 | end 10 | 11 | def start_link(config) do 12 | Supervisor.start_link(__MODULE__, config) 13 | end 14 | 15 | def init(config) do 16 | port = Keyword.fetch!(config, :port) 17 | handler = Keyword.fetch!(config, :handler) 18 | opts = [:binary, packet: :raw, active: false, reuseaddr: true, backlog: 1024] 19 | 20 | {port, opts} = case port do 21 | {:local, _} = unix -> {0, Keyword.put(opts, :ifaddr, unix)} 22 | port -> {port, opts} 23 | end 24 | 25 | {:ok, socket} = :gen_tcp.listen(port, opts) 26 | 27 | opts = [[socket: socket, handler: handler]] 28 | children = [ 29 | Supervisor.child_spec({ExWs.Acceptor, opts}, id: :ws_acceptor_1), 30 | Supervisor.child_spec({ExWs.Acceptor, opts}, id: :ws_acceptor_2), 31 | Supervisor.child_spec({ExWs.Acceptor, opts}, id: :ws_acceptor_3), 32 | Supervisor.child_spec({ExWs.Acceptor, opts}, id: :ws_acceptor_4) 33 | ] 34 | Supervisor.init(children, strategy: :one_for_one) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Supervisor do 2 | use Supervisor 3 | 4 | def start_link(opts) do 5 | Supervisor.start_link(__MODULE__, opts) 6 | end 7 | 8 | def init(opts) do 9 | children = [ 10 | {ExWs.Server, opts} 11 | ] 12 | 13 | Supervisor.init(children, strategy: :one_for_one) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/writer.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Writer do 2 | import Bitwise, only: [bor: 2] 3 | 4 | @op_txt bor(128, 1) 5 | @op_bin bor(128, 2) 6 | @op_ping bor(128, 9) 7 | @op_pong bor(128, 10) 8 | @op_close bor(128, 8) 9 | 10 | @empty_ping <<@op_ping, 0>> 11 | @empty_pong <<@op_pong, 0>> 12 | @empty_close <<@op_close, 0>> 13 | 14 | def ping(), do: @empty_ping 15 | 16 | def pong(<<>>), do: @empty_pong 17 | def pong(payload), do: [@op_pong, encode_length(payload), payload] 18 | 19 | def close({:framed, _} = framed), do: framed 20 | def close(payload) when payload in [nil, [], <<>>] do 21 | {:framed, @empty_close} 22 | end 23 | 24 | def close(payload, code) when byte_size(payload) < 123 do 25 | {:framed, [@op_close, encode_length(payload, 2), <>, payload]} 26 | end 27 | 28 | def close_echo(payload) do 29 | {:framed, [@op_close, encode_length(payload), payload]} 30 | end 31 | 32 | def to_binary({:framed, data}), do: {:framed, :erlang.iolist_to_binary(data)} 33 | 34 | def bin({:framed, _} = framed), do: framed 35 | def bin(payload), do: {:framed, [@op_bin, encode_length(payload), payload]} 36 | 37 | def txt({:framed, _} = framed), do: framed 38 | def txt(payload), do: {:framed, [@op_txt, encode_length(payload), payload]} 39 | 40 | defp encode_length(data, add \\ 0) 41 | defp encode_length(nil, 0), do: 0 42 | defp encode_length(data, add) do 43 | len = :erlang.iolist_size(data) + add 44 | cond do 45 | len < 126 -> len 46 | len < 65_536 -> <<126, len::big-16>> 47 | true -> <<127, len::big-64>> 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exws, 7 | deps: deps(), 8 | version: "0.0.3", 9 | elixir: "~> 1.14", 10 | elixirc_paths: paths(Mix.env), 11 | build_embedded: Mix.env == :prod, 12 | start_permanent: Mix.env == :prod, 13 | compilers: Mix.compilers, 14 | description: "Elixir Websocket Server - Kitchen Sink Not Included", 15 | package: [ 16 | licenses: ["MIT"], 17 | links: %{ 18 | "https://github.com/karlseguin/exws" => "https://github.com/karlseguin/exws" 19 | }, 20 | maintainers: ["Karl Seguin"] 21 | ] 22 | ] 23 | end 24 | 25 | defp paths(:ab), do: paths(:test) 26 | defp paths(:test), do: paths(:prod) ++ ["test/support"] 27 | defp paths(_), do: ["lib"] 28 | 29 | def application do 30 | [ 31 | extra_applications: [:crypto, :logger] 32 | ] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 38 | {:jiffy, git: "https://github.com/karlseguin/jiffy", only: [:test, :ab]} 39 | ] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, 3 | "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, 4 | "jiffy": {:git, "https://github.com/karlseguin/jiffy", "9252ba5d24caf48f65d8a540f2608ea05dca7a8c", []}, 5 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 9 | } 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Dependency-Free, Compliant Websocket Server written in Elixir. 2 | 3 | Kitchen sink *not* included. Every mandatory [Autobahn Testsuite](https://github.com/crossbario/autobahn-testsuite) case is passing. (Three fragmented UTF-8 are flagged as non-strict and as compression is not implemented, these are all flagged as "Unimplemented") 4 | 5 | If you're looking for a channel/room implementation, check out [ExWsChannels](https://github.com/karlseguin/exws_channels). 6 | 7 | ## Example 8 | ```elixir 9 | defmodule YourApp.YourWSHandler do 10 | # This is the only function you HAVE to define. 11 | # Note that WebSocket messages are just bytes that could represent 12 | # anything. ExWs exposes these bytes as-is (as an iodata). In most 13 | # cases, you'll probably want to decode that using JSON and process 14 | # the resulting payload, but exposing the raw bytes allows for 15 | # a lot more possibilities. 16 | def message(data, state) do 17 | # be careful, data is an iodata 18 | case Jason.decode(data) do 19 | {:ok, data} -> process(data) 20 | _ -> close(3000, "invalid payload") 21 | end 22 | end 23 | 24 | defp process(%{"join" => channel}) do 25 | #... 26 | end 27 | end 28 | ``` 29 | 30 | ## Usage 31 | 32 | Include the dependency in your project: 33 | 34 | ``` 35 | {:exms, "~> 0.0.1"} 36 | ``` 37 | 38 | Define your handler: 39 | ```elixir 40 | defmodule YourApp.YourWSHandler do 41 | use ExWs.Handler 42 | 43 | def message(_data, state) do 44 | # do something with data 45 | state 46 | end 47 | end 48 | ``` 49 | 50 | And start the server in your supervisor tree: 51 | ```elixir 52 | children = [ 53 | # ... 54 | {ExWs.Supervisor, [port: 4545, handler: YourApp.YourWSHandler]} 55 | ] 56 | ``` 57 | 58 | ## Writing 59 | From within your handler, you can use the `write/1` function to 60 | send a message to the user: 61 | 62 | ```elixir 63 | def message(data, state) do 64 | # data is an iolist 65 | write(data) # echo the message back to the user 66 | state 67 | end 68 | ``` 69 | 70 | ## Handshake 71 | The `handshake/3` callback lets you handle the initial handshake: 72 | 73 | ```elixir 74 | # this is the default implementation 75 | def handshake(_path, _headers, state) do 76 | {:ok, state} 77 | end 78 | ``` 79 | 80 | Where `path` is the requested URL path as a string, and `headers` is a map with lowercase string keys. 81 | 82 | If you want to reject the handshake, say because the path/headers does not contain the correct authentication, return a `{:close, ExWs.invalid_handshake/1}`: 83 | 84 | ```elixir 85 | def handshake(_path, headers, state) do 86 | case lookup_one_time_token(headers["token"]) do 87 | {:ok, user_id} -> {:ok, %{user_id: user_id}} # set a new state 88 | _ -> {:close, ExWs.invalid_handshake("invalid_token")} 89 | end 90 | end 91 | ``` 92 | 93 | Note that the value given to `ExWs.invalid_handshale/1` (in the above case, we're talking about "invalid_token") is placed in the `Error` header of the handshake response (for troubleshooting purpose) 94 | 95 | 96 | ### init 97 | You can set the initial state by providing an `init/0` callback: 98 | 99 | ```elixir 100 | # this is the default implementation 101 | def init(), do: nil 102 | ```` 103 | 104 | ### Closed 105 | The `closed/2` callback is called whenever the socket is closed: 106 | 107 | ```elixir 108 | # default closed/2 implementation 109 | defp closed(_reason, state) do 110 | shutdown() 111 | state 112 | end 113 | ``` 114 | 115 | If you overwrite `closed/2`, you almost certainly want to call `shutdown/0` (it both closes the socket and shuts down the underlying GenServer). 116 | 117 | ## Handler Functions 118 | Within your handler, the following functions are available: 119 | 120 | - `ping/0` send a ping message to the client 121 | - `write/1` writes the message to the client 122 | - `close/0` close the connection 123 | - `close2/` close the connection specifying a `code` and `message`. As per the specs, your `code` should be 3000-4999. Your message must be < 123 bytes. 124 | - `get_socket/0` gets the underlying socket 125 | 126 | Note that `get_socket/0` will return the socket during `init/0` and `closed/2` (but the socket can be closed by the other side at any point). 127 | 128 | Note that if you call `close/0` directly, the `closed/2` callback will be executed. 129 | 130 | ## Write Optimizations 131 | All WebSocket messages are framed and there's some overhead in creating this framing. When you call `write/1` with a binary value, the handler will frame your payload and write the framed message to the socket. 132 | 133 | For static messages, you can opt to pre-frame the message using the `ExWs.bin/1` and `ExWs.txt/1` functions. `write/1` will detect these pre-framed messages and send them directly as-is. 134 | 135 | ```elixir 136 | defmodule YourApp.YourWSHandler do 137 | use ExWs.Handler 138 | 139 | @message_over_9000 ExWs.txt(" 9000!!") 140 | def message("it's over", state) do 141 | write(@message_over_9000) 142 | state 143 | end 144 | end 145 | ``` 146 | 147 | ## txt vs bin 148 | WebSocket has a separate message type for binary data and text data. Implementations must reject any message declared as txt which is not valid UTF8. 149 | 150 | This library does not do this validation. The `message/2` callback receives both text and binary messages. 151 | 152 | If you want to differentiate between the two, implement `message/3` instead of `message/2`: 153 | 154 | ```elixir 155 | def message(op, data, state) do 156 | # op will be :txt or :bin 157 | end 158 | ``` 159 | 160 | The default `write/1` function uses the text type. You can override `write/1` to change this behavior: 161 | 162 | ```elixir 163 | defp write(data) do 164 | ExWs.write(get_socket(), ExWs.bin(data)) 165 | end 166 | ``` 167 | 168 | Or you can do it on a case-by-case basis: 169 | ```elixir 170 | write(ExWs.bin(some_data)) 171 | 172 | write(data) 173 | # as as 174 | write(ExBin.txt(data)) 175 | ``` 176 | 177 | ## Direct Socket Usage 178 | For performance reason, you may want to write directly to the socket, without going through the handler. For example, you might implement room/channel logic by storing the socket directly into the ETS table (writing to sockets from concurrent elixir processes is fine). 179 | 180 | As we already saw, the `get_socket/0` helper will return the socket. But you cannot write to the socket directly using `gen_tcp.send/2` since weboscket messages must be framed. 181 | 182 | You have two options, either use the `ExWs.bin/1` and `ExWs.txt/1` helpers to frame data: 183 | 184 | ```elixir 185 | :gen_tcp.send(socket, ExWs.txt("leto atreides")) 186 | ``` 187 | 188 | Or use the `ExWs.write/2` helper: 189 | 190 | ```elixir 191 | ExWs.write(socket, "leto atreides") 192 | ``` 193 | 194 | Note that `ExWs` also exposes `ping/1` and `close/1`. 195 | -------------------------------------------------------------------------------- /test/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests.Integration do 2 | use ExWs.Tests 3 | alias ExWs.Tests.WS 4 | 5 | setup_all do 6 | ExWs.GenTcpFake.real() 7 | end 8 | 9 | setup do 10 | :ets.delete_all_objects(:integration_tests) 11 | :ets.insert(:integration_tests, {:test_pid, self()}) 12 | :ok 13 | end 14 | 15 | test "handshake timeout" do 16 | WS.connect(4545) 17 | receive do 18 | {:closed, reason} -> assert reason == :reject_handshake 19 | end 20 | :timer.sleep(50) 21 | assert Process.alive?(get_handler()) == false 22 | end 23 | 24 | test "handshake error" do 25 | ws = WS.handshake(4545, "/fail") 26 | assert ws.status == 400 27 | assert ws.headers["error"] == "handshake_fail" 28 | assert :gen_tcp.recv(ws.socket, 0, 100) == {:error, :closed} 29 | :timer.sleep(50) 30 | assert Process.alive?(get_handler()) == false 31 | end 32 | 33 | test "client disconnect" do 34 | WS.kill(WS.handshake(4545, "/")) 35 | receive do 36 | {:closed, reason} -> assert reason == :tcp_closed 37 | end 38 | :timer.sleep(50) 39 | assert Process.alive?(get_handler()) == false 40 | end 41 | 42 | defp get_handler() do 43 | case :ets.lookup(:integration_tests, :handler_pid) do 44 | [{:handler_pid, value}] -> value 45 | _ -> nil 46 | end 47 | end 48 | end 49 | 50 | defmodule ExWs.Tests.Integration.Handler do 51 | use ExWs.Handler, 52 | handshake_timeout: 50 53 | 54 | @handshake_fail ExWs.invalid_handshake("handshake_fail") 55 | 56 | def init() do 57 | :ets.insert(:integration_tests, {:handler_pid, self()}) 58 | %{} 59 | end 60 | 61 | def handshake("/fail", _headers, _state) do 62 | {:close, @handshake_fail} 63 | end 64 | 65 | def handshake(_path, _headers, state) do 66 | {:ok, state} 67 | end 68 | 69 | def closed(reason, state) do 70 | if Map.get(state, :no_close) == nil || reason != :tcp_closed do 71 | send_to_test({:closed, reason}) 72 | shutdown() 73 | end 74 | state 75 | end 76 | 77 | def message(data, state) do 78 | data = :jiffy.decode(data) 79 | handle_message(data.action, data, state) 80 | end 81 | 82 | defp handle_message("no_close", _, state) do 83 | Map.put(state, :no_close, true) 84 | end 85 | 86 | defp send_to_test(message) do 87 | case :ets.lookup(:integration_tests, :test_pid) do 88 | [{:test_pid, pid}] -> send(pid, message) 89 | _ -> :ok 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/support/ab_client.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests.ABHandler do 2 | use ExWs.Handler 3 | 4 | defp message(:bin, data, state) do 5 | write(ExWs.bin(data)) 6 | state 7 | end 8 | 9 | defp message(:txt, data, state) do 10 | case String.valid?(:erlang.iolist_to_binary(data)) do 11 | true -> 12 | write(data) 13 | state 14 | false -> 15 | close("invalid utf8", 1007) 16 | state 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/gen_tcp_fake.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.GenTcpFake do 2 | use GenServer 3 | 4 | @name __MODULE__ 5 | 6 | def start_link() do 7 | GenServer.start_link(__MODULE__, [], name: @name) 8 | end 9 | 10 | def init(_), do: {:ok, do_reset()} 11 | def real(), do: GenServer.cast(@name, :real) 12 | def fake(), do: GenServer.cast(@name, :fake) 13 | def reset(buffer \\ []), do: GenServer.cast(@name, {:reset, buffer}) 14 | def close(socket), do: GenServer.call(@name, {:close, socket}) 15 | def send(socket, data), do: GenServer.call(@name, {:send, socket, data}) 16 | def setopts(socket, opts), do: GenServer.call(@name, {:setops, socket, opts}) 17 | def sent(), do: GenServer.call(@name, :sent) 18 | def closed?(), do: GenServer.call(@name, :closed?) 19 | def recv(socket, len, timeout \\ 0) do 20 | GenServer.call(@name, {:recv, socket, len, timeout}) 21 | catch 22 | :exit, {:timeout, _} -> {:error, :timeout} 23 | end 24 | 25 | def handle_cast(:real, state) do 26 | {:noreply, %{state | fake: false}} 27 | end 28 | 29 | def handle_cast(:fake, state) do 30 | {:noreply, %{state | fake: true}} 31 | end 32 | 33 | def handle_cast({:reset, buffer}, %{fake: true}) do 34 | {:noreply, do_reset(buffer)} 35 | end 36 | 37 | def handle_call({:close, _socket}, _from, %{fake: true} = state) do 38 | {:reply, :ok, %{state | closed: true}} 39 | end 40 | 41 | def handle_call({:close, socket}, _from, %{fake: false} = state) do 42 | :gen_tcp.close(socket) 43 | {:reply, :ok, state} 44 | end 45 | 46 | def handle_call({:setops, _socket, _opts}, _from, %{fake: true} = state) do 47 | {:reply, :ok, state} 48 | end 49 | 50 | def handle_call({:setops, socket, opts}, _from, %{fake: false} = state) do 51 | :inet.setopts(socket, opts) 52 | {:reply, :ok, state} 53 | end 54 | 55 | def handle_call({:send, _socket, data}, _from, %{fake: true} = state) do 56 | {:reply, :ok, %{state | sent: [state.sent, data]}} 57 | end 58 | 59 | def handle_call({:send, socket, data}, _from, %{fake: false} = state) do 60 | :gen_tcp.send(socket, data) 61 | {:reply, :ok, state} 62 | end 63 | 64 | def handle_call(:sent, _from, %{fake: true} = state) do 65 | {:reply, :erlang.iolist_to_binary(state.sent), %{state | sent: []}} 66 | end 67 | 68 | def handle_call({:recv, _socket, _len, _timeout}, _from, %{fake: true} = state) do 69 | [data | buffer] = state.buffer 70 | 71 | data = case data do 72 | :closed -> {:error, :closed} 73 | data -> {:ok, data} 74 | end 75 | 76 | {:reply, data, %{state | buffer: buffer}} 77 | end 78 | 79 | def handle_call({:recv, socket, len, timeout}, _from, %{fake: false} = state) do 80 | {:reply, :gen_tcp.recv(socket, len, timeout), state} 81 | end 82 | 83 | def handle_call(:closed?, _from, %{fake: true} = state) do 84 | {:reply, state.closed, %{state | closed: false}} 85 | end 86 | 87 | defp do_reset(buffer \\ []) do 88 | %{ 89 | sent: [], 90 | fake: true, 91 | closed: false, 92 | buffer: List.flatten(buffer), 93 | } 94 | end 95 | 96 | end 97 | -------------------------------------------------------------------------------- /test/support/tests.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests do 2 | use ExUnit.CaseTemplate 3 | end 4 | -------------------------------------------------------------------------------- /test/support/ws.ex: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests.WS do 2 | import ExUnit.Assertions 3 | 4 | alias __MODULE__ 5 | 6 | defstruct [:socket, :status, :headers] 7 | 8 | def connect(port) do 9 | {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) 10 | %WS{socket: socket} 11 | end 12 | 13 | def handshake(port, path, headers \\ %{}) 14 | 15 | def handshake(port, path, headers) when is_integer(port) do 16 | port 17 | |> connect() 18 | |> handshake(path, headers) 19 | end 20 | 21 | def handshake(ws, path, headers) do 22 | socket = ws.socket 23 | :ok = :gen_tcp.send(socket, "GET #{path} HTTP/1.1\r\n") 24 | :ok = :gen_tcp.send(socket, "upgrade: WEBsocKet\r\n") 25 | :ok = :gen_tcp.send(socket, "connection: upgrade\r\n") 26 | :ok = :gen_tcp.send(socket, "sec-websocket-version: 13\r\n") 27 | :ok = :gen_tcp.send(socket, "host: test.openmymind.net\r\n") 28 | :ok = :gen_tcp.send(socket, "sec-websocket-key: #{Base.encode64(Integer.to_string(:rand.uniform(1_000_000) + 1_000_000))}\r\n") 29 | Enum.each(headers, fn {k, v} -> :ok = :gen_tcp.send(socket, "#{k}: #{v}\r\n") end) 30 | :ok = :gen_tcp.send(socket, "\r\n") 31 | 32 | :inet.setopts(socket, packet: :http) 33 | {:ok, {:http_response, {1, 1}, status, _}} = :gen_tcp.recv(socket, 0, 1000) 34 | headers = Enum.reduce_while(1..100, %{}, fn _, headers -> 35 | case :gen_tcp.recv(socket, 0, 100) do 36 | {:ok, :http_eoh} -> {:halt, headers} 37 | {:ok, {:http_header, _, name, _, value}} -> {:cont, Map.put(headers, String.downcase(to_string(name)), to_string(value))} 38 | err -> flunk "handshake response line: #{inspect err}" 39 | end 40 | end) 41 | :inet.setopts(socket, packet: :raw) 42 | %WS{ws | status: status, headers: headers} 43 | end 44 | 45 | def closed?(%{socket: socket}) do 46 | closed?(socket) 47 | end 48 | 49 | def closed?(socket) do 50 | {:error, :closed} == :gen_tcp.recv(socket, 0, 10) 51 | end 52 | 53 | def read_close(%{socket: socket}) do 54 | {8, <>} = read(socket) 55 | assert closed?(socket) == true 56 | {code, message} 57 | end 58 | 59 | def read(%{socket: socket}), do: read(socket) 60 | 61 | def read(socket) do 62 | {:ok, data} = :gen_tcp.recv(socket, 2, 1000) 63 | # fin, rsv, rsv2, rsv3, op::4, mask... 64 | <<1::1, _::1, _::1, _::1, op::4, 0::1, len::7>> = data 65 | 66 | len = case len do 67 | 127 -> {:ok, <>} = :gen_tcp.recv(socket, 8, 1000); len 68 | 126 -> {:ok, <>} = :gen_tcp.recv(socket, 2, 1000); len 69 | _ -> len 70 | end 71 | 72 | case len == 0 do 73 | true -> {op, ""} 74 | false -> 75 | {:ok, data} = :gen_tcp.recv(socket, len, 1000) 76 | {op, data} 77 | end 78 | end 79 | 80 | def read_bin(s) do 81 | {2, data} = read(s) 82 | data 83 | end 84 | 85 | def read_json(s) do 86 | {op, data} = read(s) 87 | assert op == 2 || op == 1 88 | :jiffy.decode(data) 89 | end 90 | 91 | def read_json(s, prefix) do 92 | {op, <<^prefix, data::binary>>} = read(s) 93 | assert op == 2 || op == 1 94 | :jiffy.decode(data) 95 | end 96 | 97 | def empty?(%{socket: socket}), do: empty?(socket) 98 | 99 | def empty?(socket) do 100 | case :gen_tcp.recv(socket, 1, 20) do 101 | {:error, :timeout} -> true 102 | other -> other 103 | end 104 | end 105 | 106 | def write(%{socket: socket} = ws, data) do 107 | write(socket, data) 108 | ws 109 | end 110 | 111 | def write(socket, data) when is_map(data) do 112 | write(socket, :jiffy.encode(data)) 113 | end 114 | 115 | def write(socket, data) do 116 | length = :erlang.iolist_size(data) 117 | 118 | # MSB has to be 1 to indicate that our data is masked 119 | length = cond do 120 | length < 125 -> 128 + length 121 | length < 65536 -> <<254, length::big-integer-16>> 122 | true -> <<255, length::big-integer-64>> 123 | end 124 | 125 | data = [ 126 | 130, # fin + bin data 127 | length, 128 | <<0, 0, 0, 0>>, # mask of zero (teehee) 129 | data 130 | ] 131 | :gen_tcp.send(socket, data) 132 | end 133 | 134 | 135 | def kill(ws), do: :gen_tcp.close(ws.socket) 136 | end 137 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | :ets.new(:integration_tests, [:set, :public, :named_table]) 2 | 3 | ExWs.GenTcpFake.start_link() 4 | ExWs.Supervisor.start_link(Application.get_all_env(:exws)) 5 | 6 | ExUnit.start(exclude: [:skip]) 7 | -------------------------------------------------------------------------------- /test/ws/frame_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests.Frame do 2 | use ExWs.Tests 3 | import Bitwise, only: [bor: 2] 4 | 5 | alias ExWs.Mask 6 | alias ExWs.Reader 7 | 8 | test "error on unknown op" do 9 | assert_error(received(build(op: 20)), 1002, "invalid op") 10 | end 11 | 12 | test "error if unmasked" do 13 | assert_error(received(build(mask: false)), 1002, "unmasked frame") 14 | end 15 | 16 | test "error on large control" do 17 | body = String.duplicate("!", 126) 18 | for op <- [:close, :ping, :pong] do 19 | assert_error(received(build(op: op, data: body)), 1002, "large control") 20 | end 21 | end 22 | 23 | test "error on unexpected cont" do 24 | assert_error(received(build(op: :cont)), 1002, "invalid continuation") 25 | end 26 | 27 | test "error on expected but missing cont" do 28 | {:ok, reader} = received(build(fin: false)) 29 | assert_error(received(build(op: :txt), reader), 1002, "invalid continuation") 30 | end 31 | 32 | test "parses a full bodyless" do 33 | data = build(fin: true, op: :txt) 34 | assert {:ok, [{:txt, <<>>}], reader} = received(data) 35 | assert_new(reader) 36 | end 37 | 38 | test "parses a full small-body" do 39 | data = build(fin: true, op: :bin, data: "hello") 40 | assert {:ok, [{:bin, "hello"}], reader} = received(data) 41 | assert_new(reader) 42 | end 43 | 44 | test "parses a full medium-body" do 45 | body = String.duplicate("a", 200) 46 | data = build(fin: true, op: :txt, data: body) 47 | assert {:ok, [{:txt, ^body}], reader} = received(data) 48 | assert_new(reader) 49 | end 50 | 51 | test "parses a full large-body" do 52 | body = String.duplicate("b", 65537) 53 | data = build(fin: true, op: :txt, data: body) 54 | assert {:ok, [{:txt, ^body}], reader} = received(data) 55 | assert_new(reader) 56 | end 57 | 58 | test "parses two full bodyless" do 59 | data = build(fin: true, op: :bin) <> build(fin: true, op: :txt) 60 | assert {:ok, [{:bin, <<>>}, {:txt, <<>>}], reader} = received(data) 61 | assert_new(reader) 62 | end 63 | 64 | test "parses two full small-body" do 65 | data = build(fin: true, op: :txt, data: "over") <> build(fin: true, op: :txt, data: "9000!") 66 | assert {:ok, [{:txt, "over"}, {:txt, "9000!"}], reader} = received(data) 67 | assert_new(reader) 68 | end 69 | 70 | test "parses two full med-body" do 71 | b1 = String.duplicate("c", 200) 72 | b2 = String.duplicate("d", 9001) 73 | data = build(fin: true, op: :txt, data: b1) <> build(fin: true, op: :txt, data: b2) 74 | assert {:ok, [{:txt, ^b1}, {:txt, ^b2}], reader} = received(data) 75 | assert_new(reader) 76 | end 77 | 78 | test "parses two full large-body" do 79 | b1 = String.duplicate("e", 65537) 80 | b2 = String.duplicate("f", 75537) 81 | data = build(fin: true, op: :bin, data: b1) <> build(fin: true, op: :bin, data: b2) 82 | assert {:ok, [{:bin, ^b1}, {:bin, ^b2}], reader} = received(data) 83 | assert_new(reader) 84 | end 85 | 86 | test "parses ws fragmented message" do 87 | {:ok, reader} = received(build(fin: false, data: "over ")) 88 | assert {:ok, [{:bin, "over 9000!!!"}], reader} = received(build(op: :cont, data: "9000!!!"), reader) 89 | assert_new(reader) 90 | end 91 | 92 | test "parses ws frabingmented message with interleaved control" do 93 | {:ok, reader} = received(build(fin: false, data: "over ")) 94 | {:ok, [{:ping, "ping?"}], reader} = received(build(op: :ping, data: "ping?"), reader) 95 | {:ok, reader} = received(build(fin: false, op: :cont, data: "9000"), reader) 96 | assert {:ok, [{:bin, "over 9000!"}], reader} = received(build(op: :cont, data: "!"), reader) 97 | assert_new(reader) 98 | end 99 | 100 | test "parses a data-less message with tcp fragmentation" do 101 | <> = build(data: "leto") 102 | assert_fragment([op, data], "leto") 103 | end 104 | 105 | test "parses a tcp fragmented frame" do 106 | data = :erlang.binary_to_list(build(data: "leto")) 107 | Enum.reduce(data, Reader.new(), fn c, reader -> 108 | case received(<>, reader) do 109 | {:ok, reader} -> reader 110 | {:ok, [{:bin, "leto"}], reader} -> assert_new(reader); :invalid_acc 111 | end 112 | end) 113 | end 114 | 115 | test "parses a tcp fragmented medium sized frame" do 116 | body = String.duplicate("a", 150) 117 | data = :erlang.binary_to_list(build(data: body)) 118 | Enum.reduce(data, Reader.new(), fn c, reader -> 119 | case received(<>, reader) do 120 | {:ok, reader} -> reader 121 | {:ok, [{:bin, ^body}], reader} -> assert_new(reader); :invalid_acc 122 | end 123 | end) 124 | end 125 | 126 | test "parses a tcp fragmented large sized frame" do 127 | body = String.duplicate("a", 17828) 128 | data = :erlang.binary_to_list(build(data: body)) 129 | Enum.reduce(data, Reader.new(), fn c, reader -> 130 | case received(<>, reader) do 131 | {:ok, reader} -> reader 132 | {:ok, [{:bin, ^body}], reader} -> assert_new(reader); :invalid_acc 133 | end 134 | end) 135 | end 136 | 137 | defp assert_fragment(fragments, expected_data) do 138 | Enum.reduce(fragments, Reader.new(), fn fragment, reader -> 139 | case received(fragment, reader) do 140 | {:ok, reader} -> reader 141 | {:ok, [{:bin, actual}], reader} -> 142 | assert expected_data == actual 143 | reader 144 | end 145 | end) 146 | end 147 | 148 | defp assert_error({:ok, {:close, {:framed, data}}, _frame}, expected_code, expected_message) do 149 | # 136 fin | close (128 | 8) 150 | <<136, len::8, ^expected_code::big-16, ^expected_message::binary>> = data 151 | # close message is ALWAYS < 126 and the mask flag is off (server messages are never masked) 152 | assert len < 126 153 | end 154 | 155 | defp assert_new(reader) do 156 | assert reader.op == nil 157 | assert reader.fin == nil 158 | assert reader.len == nil 159 | assert reader.data == nil 160 | assert reader.chain == nil 161 | assert reader.mask == {4, <<>>} 162 | end 163 | 164 | defp received(data) do 165 | received(data, Reader.new()) 166 | end 167 | 168 | defp received(data, reader) do 169 | case Reader.received(data, reader) do 170 | {:ok, messages, reader} when is_list(messages) -> 171 | messages = Enum.map(messages, fn {k, v} -> 172 | {k, :erlang.iolist_to_binary(v)} 173 | end) 174 | {:ok, messages, reader} 175 | other -> other 176 | end 177 | end 178 | 179 | defp build(opts) do 180 | op = case opts[:op] do 181 | :cont -> 0 182 | :txt -> 1 183 | :close -> 8 184 | :ping -> 9 185 | :pong -> 10 186 | op when is_integer(op) -> op 187 | _ -> 2 188 | end 189 | 190 | b1 = case opts[:fin] do 191 | false -> <> 192 | _ -> <> 193 | end 194 | 195 | mask = case opts[:mask] do 196 | nil -> <<:rand.uniform(4294967295)::big-32>> # almost all test want a mask, so make this the default 197 | false -> <<>> 198 | end 199 | 200 | data = opts[:data] || <<>> 201 | len = opts[:len] || byte_size(data) 202 | 203 | mask_flag = case byte_size(mask) == 0 do 204 | true -> 0 205 | false -> 128 206 | end 207 | 208 | len = cond do 209 | len < 126 -> <> 210 | len < 65536 -> <> 211 | true -> <> 212 | end 213 | 214 | data = case mask do 215 | <<>> -> data 216 | mask -> :erlang.iolist_to_binary(Mask.apply(mask, data)) 217 | end 218 | 219 | b1 <> len <> mask <> data 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /test/ws/handshake_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExWs.Tests.Hanshake do 2 | use ExWs.Tests 3 | alias ExWs.GenTcpFake 4 | 5 | setup_all do 6 | ExWs.GenTcpFake.fake() 7 | end 8 | 9 | test "invalid method" do 10 | for method <- ["", " ", "POST", "DELETE", "post", "g_et", " get"] do 11 | assert_error(["#{method} / http/1.1\r\n"], "method") 12 | end 13 | end 14 | 15 | test "invalid path" do 16 | for path <- ["" , " ",] do 17 | assert_error(["GET #{path} http/1.1\r\n"], "path") 18 | end 19 | end 20 | 21 | test "invalid protocol" do 22 | for protocol <- ["http/1.0" , "http/1.1a", "", " "] do 23 | assert_error(["get / #{protocol}\r\n"], "protocol") 24 | end 25 | end 26 | 27 | test "invalid headers" do 28 | for headers <- ["hi\r\n", "over 9000\r\n"] do 29 | assert_error([request_line(), headers], "headers") 30 | end 31 | end 32 | 33 | @tag capture_log: true 34 | test "closed on header reading" do 35 | assert_closed([request_line(), :closed]) 36 | assert_closed([request_line(), "header: value\r\n", :closed]) 37 | end 38 | 39 | test "error on missing key" do 40 | assert_error([request_line(), "header: value\r\n", "\r\n"], "key") 41 | end 42 | 43 | test "error on missing host" do 44 | assert_error([request_line(), "sec-websocket-key: 123\r\n", "\r\n"], "host") 45 | end 46 | 47 | test "error on missing or invalid version" do 48 | valid = [request_line(), "sec-websocket-key: 1\r\n", "host: a\r\n"] 49 | assert_error([valid, "\r\n"], "version") 50 | assert_error([valid, "sec-websocket-version: \r\n", "\r\n"], "version") 51 | assert_error([valid, "sec-websocket-version: 11", "\r\n"], "version") 52 | assert_error([valid, "sec-websocket-version: 12", "\r\n"], "version") 53 | assert_error([valid, "sec-websocket-version: thirteen", "\r\n"], "version") 54 | end 55 | 56 | test "error on missing or invalid upgrade" do 57 | valid = [request_line(), "SEC-WEBSOCKET-KEY: longer \r\n", "HOST: a longer host \r\n", "sec-websocket-version: 13\r\n"] 58 | assert_error([valid, "\r\n"], "upgrade") 59 | assert_error([valid, "upgrade: \r\n", "\r\n"], "upgrade") 60 | assert_error([valid, "upgrade: no", "\r\n"], "upgrade") 61 | assert_error([valid, "upgrade: test", "\r\n"], "upgrade") 62 | assert_error([valid, "upgrade: 323", "\r\n"], "upgrade") 63 | end 64 | 65 | test "error on missing or invalid connection" do 66 | valid = [request_line(), "Sec-WebsockeT-kEY: 239a9jk3 \r\n", "Host: www.x.com\r\n", "Sec-Websocket-version: 13\r\n", "upgrade: websocket\r\n"] 67 | assert_error([valid, "\r\n"], "connection") 68 | assert_error([valid, "connection: \r\n", "\r\n"], "connection") 69 | assert_error([valid, "connection: no", "\r\n"], "connection") 70 | assert_error([valid, "connection: test", "\r\n"], "connection") 71 | assert_error([valid, "connection: 323", "\r\n"], "connection") 72 | end 73 | 74 | test "successful" do 75 | assert_success(path: "/", key: "abc123", host: "test.com") 76 | assert_success(path: "?test-1", key: "1230919a", host: "test.net") 77 | end 78 | 79 | defp request_line() do 80 | :erlang.iolist_to_binary([ 81 | Enum.random(["GET", "get", "Get", "gEt", "GEt", "gET"]), 82 | " ", 83 | Enum.random(["/", "/socket", "/ws", "/?over=9000"]), 84 | " ", 85 | Enum.random(["HTTP/1.1", "http/1.1", "Http/1.1", "hTTp/1.1"]), 86 | "\r\n" 87 | ]) 88 | end 89 | 90 | defp assert_error(buffer, error) do 91 | assert read(buffer) == :closed 92 | sent = GenTcpFake.sent() 93 | assert String.starts_with?(sent, "HTTP/1.1 400 Bad Request\r\n") == true 94 | assert sent =~ "Error: #{error}\r\n" 95 | assert sent =~ "Content-Length: 0\r\n" 96 | assert GenTcpFake.closed? == true 97 | end 98 | 99 | defp assert_success(opts) do 100 | req = [ 101 | "GET #{opts[:path]} HTTP/1.1\r\n", 102 | "host: #{opts[:host]}\r\n", 103 | "Upgrade: WeBSocket\r\n", 104 | "Connection: upgrADe \r\n", 105 | "sec-websocket-version: 13\r\n", 106 | "sec-websocket-key: #{opts[:key]}\r\n", 107 | "\r\n" 108 | ] 109 | 110 | accept_key = :sha 111 | |> :crypto.hash([opts[:key], "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"]) 112 | |> Base.encode64() 113 | 114 | {:ok, path, headers, _socket} = read(req) 115 | assert headers[:ip] == opts[:ip] 116 | assert path == opts[:path] 117 | 118 | ExWs.Handshake.accept(nil, headers) 119 | assert GenTcpFake.sent() == "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept_key}\r\n\r\n" 120 | assert GenTcpFake.closed? == false 121 | end 122 | 123 | defp assert_closed(buffer) do 124 | read(buffer) 125 | assert GenTcpFake.sent() == "" 126 | assert GenTcpFake.closed? == true 127 | end 128 | 129 | defp read(buffer) do 130 | GenTcpFake.reset(List.flatten(buffer)) 131 | ExWs.Handshake.read(nil) 132 | end 133 | end 134 | --------------------------------------------------------------------------------