diff --git a/lib/ex_stun/client.ex b/lib/ex_stun/client.ex deleted file mode 100644 index c342189..0000000 --- a/lib/ex_stun/client.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule ExSTUN.Client do - @moduledoc """ - STUN Client - """ - use GenServer - - alias ExSTUN.Message - - def start_link(address, port) do - GenServer.start_link(__MODULE__, [address, port]) - end - - def send(pid, msg) do - GenServer.cast(pid, {:send, msg}) - end - - @impl true - def init([address, port]) do - {:ok, socket} = :gen_udp.open(5555) - :ok = :gen_udp.connect(socket, address, port) - {:ok, %{socket: socket}} - end - - @impl true - def handle_cast({:send, msg}, state) do - :ok = :gen_udp.send(state.socket, Message.encode(msg)) - {:noreply, state} - end - - @impl true - def handle_info({:udp, _socket, _ip, _port, data}, state) do - IO.iodata_to_binary(data) - |> ExSTUN.Message.decode() - - {:noreply, state} - end -end diff --git a/lib/ex_stun/uri.ex b/lib/ex_stun/uri.ex new file mode 100644 index 0000000..bf68e1a --- /dev/null +++ b/lib/ex_stun/uri.ex @@ -0,0 +1,162 @@ +defmodule ExSTUN.URI do + @moduledoc """ + Module representing STUN/TURN URI. + + Implementation of RFC 7064 and RFC 7065. + + We could try to use URI module from Elixir + but RFC 7064 and RFC 7065 state: + + While these two ABNF productions are defined in [RFC3986] + as components of the generic hierarchical URI, this does + not imply that the "stun" and "stuns" URI schemes are + hierarchical URIs. Developers MUST NOT use a generic + hierarchical URI parser to parse a "stun" or "stuns" URI. + + """ + + @type scheme :: :stun | :stuns | :turn | :turns + + @type transport :: :udp | :tcp + + @typedoc """ + Type describing URI struct. + + `transport` denotes protocol that should be used + by a client to connect to the STUN/TURN server. + `nil` means that the client should try to connect with every protocol it supports. + If scheme indicates secure connection, transport is always set to `:tcp`. + This is based on [RFC 5928, sec. 3](https://datatracker.ietf.org/doc/html/rfc5928#section-3). + """ + @type t() :: %__MODULE__{ + scheme: scheme(), + host: String.t(), + port: :inet.port_number(), + transport: transport() | nil + } + + @enforce_keys [:scheme, :host, :port] + defstruct @enforce_keys ++ [:transport] + + @default_udp_tcp_port 3478 + @default_tls_port 5349 + + @doc """ + The same as parse/1 but raises on error. + """ + @spec parse!(String.t()) :: t() + def parse!(uri) do + case parse(uri) do + {:ok, uri} -> uri + :error -> raise "Invalid URI" + end + end + + @doc """ + Parses URI string into `t:t/0`. + """ + @spec parse(String.t()) :: {:ok, t()} | :error + def parse("stun" <> ":" <> host_port) do + do_parse_stun(:stun, host_port) + end + + def parse("stuns" <> ":" <> host_port) do + do_parse_stun(:stuns, host_port) + end + + def parse("turn" <> ":" <> host_port_transport) do + do_parse_turn(:turn, host_port_transport) + end + + def parse("turns" <> ":" <> host_port_transport) do + do_parse_turn(:turns, host_port_transport) + end + + def parse(_other), do: :error + + defp do_parse_stun(scheme, host_port) do + default_port = if scheme == :stun, do: @default_udp_tcp_port, else: @default_tls_port + default_transport = if scheme == :stuns, do: :tcp + + with {:ok, host, rest} <- parse_host(host_port), + {:ok, port} <- parse_stun_port(rest) do + {:ok, + %__MODULE__{ + scheme: scheme, + host: host, + port: port || default_port, + transport: default_transport + }} + else + _ -> :error + end + end + + defp do_parse_turn(scheme, host_port_transport) do + default_port = if scheme == :turn, do: @default_udp_tcp_port, else: @default_tls_port + default_transport = if scheme == :turns, do: :tcp + + with {:ok, host, rest} <- parse_host(host_port_transport), + {:ok, port, rest} <- parse_turn_port(rest), + {:ok, transport} <- parse_transport(rest) do + {:ok, + %__MODULE__{ + scheme: scheme, + host: host, + port: port || default_port, + transport: transport || default_transport + }} + end + end + + defp parse_host(data) do + case String.split(data, ":", parts: 2) do + [host, rest] when host != "" -> + {:ok, host, rest} + + [host] when host != "" -> + case String.split(host, "?transport=") do + [host, rest] when host != "" -> {:ok, host, "?transport=" <> rest} + [host] -> {:ok, host, ""} + _ -> :error + end + + _ -> + :error + end + end + + defp parse_stun_port(""), do: {:ok, nil} + defp parse_stun_port(port), do: do_parse_port(port) + + defp parse_turn_port(""), do: {:ok, nil, ""} + defp parse_turn_port("?transport=" <> rest), do: {:ok, nil, rest} + + defp parse_turn_port(data) do + case String.split(data, "?transport=", parts: 2) do + [port, rest] -> + case do_parse_port(port) do + {:ok, port} -> {:ok, port, rest} + :error -> :error + end + + [port] -> + case do_parse_port(port) do + {:ok, port} -> {:ok, port, ""} + :error -> :error + end + end + end + + defp do_parse_port(port) do + case Integer.parse(port) do + {port, ""} -> {:ok, port} + _ -> :error + end + end + + defp parse_transport(""), do: {:ok, nil} + defp parse_transport("udp"), do: {:ok, :udp} + defp parse_transport("tcp"), do: {:ok, :tcp} + defp parse_transport(_), do: :error +end diff --git a/test/ex_stun/client_test.exs b/test/ex_stun/client_test.exs deleted file mode 100644 index 791a1aa..0000000 --- a/test/ex_stun/client_test.exs +++ /dev/null @@ -1,15 +0,0 @@ -defmodule ExSTUN.ClientTest do - use ExUnit.Case - - test "" do - {:ok, pid} = ExSTUN.Client.start_link(~c"stun.l.google.com", 19_302) - - m = - ExSTUN.Message.new(%ExSTUN.Message.Type{ - class: :request, - method: :binding - }) - - ExSTUN.Client.send(pid, m) - end -end diff --git a/test/ex_stun/uri_test.exs b/test/ex_stun/uri_test.exs new file mode 100644 index 0000000..27bdb92 --- /dev/null +++ b/test/ex_stun/uri_test.exs @@ -0,0 +1,84 @@ +defmodule ExSTUN.URITest do + use ExUnit.Case, async: true + + alias ExSTUN.URI + + describe "parse/1" do + test "with valid URI" do + for {uri_string, expected_uri} <- [ + { + "stun:stun.l.google.com:19302", + %URI{scheme: :stun, host: "stun.l.google.com", port: 19_302, transport: nil} + }, + { + "stuns:stun.l.google.com:19302", + %URI{scheme: :stuns, host: "stun.l.google.com", port: 19_302, transport: :tcp} + }, + { + "stun:stun.l.google.com", + %URI{scheme: :stun, host: "stun.l.google.com", port: 3478, transport: nil} + }, + { + "stuns:stun.l.google.com", + %URI{scheme: :stuns, host: "stun.l.google.com", port: 5349, transport: :tcp} + }, + { + "turn:example.org", + %URI{scheme: :turn, host: "example.org", port: 3478, transport: nil} + }, + { + "turns:example.org", + %URI{scheme: :turns, host: "example.org", port: 5349, transport: :tcp} + }, + { + "turn:example.org:8000", + %URI{scheme: :turn, host: "example.org", port: 8000, transport: nil} + }, + { + "turn:example.org?transport=udp", + %URI{scheme: :turn, host: "example.org", port: 3478, transport: :udp} + }, + { + "turn:example.org:1234?transport=udp", + %URI{scheme: :turn, host: "example.org", port: 1234, transport: :udp} + }, + { + "turn:example.org?transport=tcp", + %URI{scheme: :turn, host: "example.org", port: 3478, transport: :tcp} + }, + { + "turns:example.org?transport=tcp", + %URI{scheme: :turns, host: "example.org", port: 5349, transport: :tcp} + } + ] do + assert {:ok, expected_uri} == URI.parse(uri_string) + end + end + + test "with invalid URI" do + for invalid_uri_string <- [ + "", + "some random string", + "stun:", + "stun::", + "stun::19302", + "stun:?transport=", + "abcd:stun.l.google.com:19302", + "stun:stun.l.google.com:ab123", + "stuns:stun.l.google.com:ab123", + "stun:stun.l.google.com:19302?transport=udp", + "stun:stun.l.google.com:19302?transport=", + "turn:example.com:abc?transport=tcp", + "turn:example.com:12345?transport=tls", + "turn:example.com:abc" + ] do + assert :error == URI.parse(invalid_uri_string) + end + end + end + + test "parse!/1" do + assert %URI{} = URI.parse!("stun:stun.l.google.com") + assert_raise RuntimeError, fn -> URI.parse!("invalid uri") end + end +end diff --git a/test/integration_test.exs b/test/integration_test.exs new file mode 100644 index 0000000..ebb6839 --- /dev/null +++ b/test/integration_test.exs @@ -0,0 +1,22 @@ +defmodule ExSTUN.IntegrationTest do + use ExUnit.Case, async: true + + alias ExSTUN.Message + alias ExSTUN.Message.Type + alias ExSTUN.Message.Attribute.XORMappedAddress + + test "binding request/response" do + {:ok, socket} = :gen_udp.open(0, [{:active, false}, :binary]) + + req = + %Type{class: :request, method: :binding} + |> Message.new() + |> Message.encode() + + :ok = :gen_udp.send(socket, ~c"stun.l.google.com", 19_302, req) + {:ok, {_, _, resp}} = :gen_udp.recv(socket, 0) + + {:ok, %Message{} = msg} = Message.decode(resp) + assert {:ok, %XORMappedAddress{}} = Message.get_attribute(msg, XORMappedAddress) + end +end diff --git a/test/realm_test.exs b/test/realm_test.exs deleted file mode 100644 index 1960ca3..0000000 --- a/test/realm_test.exs +++ /dev/null @@ -1,6 +0,0 @@ -defmodule ExSTUN.Message.Attribute.RealmTest do - use ExUnit.Case - - test "" do - end -end