diff --git a/lib/tds/instance.ex b/lib/tds/instance.ex new file mode 100644 index 00000000..8513fe80 --- /dev/null +++ b/lib/tds/instance.ex @@ -0,0 +1,254 @@ +defmodule Tds.Instance do + @moduledoc false + + @default_browser_port 1434 + @default_timeout 3_000 + + @type resolution :: + {:ok, :inet.port_number()} + | {:fallback, :inet.port_number(), Tds.Error.t()} + | {:error, Tds.Error.t()} + + @spec resolve_port(Keyword.t()) :: resolution() + def resolve_port(opts) do + case resolve(opts) do + {:ok, port} -> + {:ok, port} + + {:error, %Tds.Error{} = instance_error} -> + case fallback_port(opts) do + {:ok, port} -> {:fallback, port, instance_error} + :none -> {:error, instance_error} + {:error, %Tds.Error{} = fallback_error} -> {:error, fallback_error} + end + end + end + + @spec resolve(Keyword.t()) :: {:ok, :inet.port_number()} | {:error, Tds.Error.t()} + def resolve(opts) do + udp_module = Keyword.get(opts, :instance_udp_module, :gen_udp) + + with {:ok, instance} <- instance_name(opts), + {:ok, timeout} <- instance_timeout(opts), + {:ok, browser_port} <- browser_port(opts), + {:ok, socket} <- open_socket(udp_module) do + try do + resolve_on_socket(udp_module, socket, opts, browser_port, timeout, instance) + after + close_socket(udp_module, socket) + end + end + end + + defp resolve_on_socket(udp_module, socket, opts, browser_port, timeout, instance) do + host = Keyword.fetch!(opts, :hostname) + host = if is_binary(host), do: String.to_charlist(host), else: host + + with :ok <- send_query(udp_module, socket, host, browser_port), + {:ok, message} <- receive_response(udp_module, socket, timeout), + {:ok, port} <- parse_response(message, browser_port, instance) do + {:ok, port} + end + rescue + exception -> error("SQL Server Browser lookup failed: #{Exception.message(exception)}") + catch + kind, reason -> error("SQL Server Browser lookup failed: #{inspect({kind, reason})}") + end + + defp open_socket(udp_module) do + case guarded_call(fn -> + udp_module.open(0, [:binary, {:active, false}, {:reuseaddr, true}]) + end) do + {:ok, {:ok, socket}} -> {:ok, socket} + {:ok, {:error, reason}} -> error("udp open failed: #{inspect(reason)}") + {:ok, other} -> error("udp open returned an unexpected result: #{inspect(other)}") + {:error, reason} -> error("udp open failed: #{inspect(reason)}") + end + end + + defp send_query(udp_module, socket, host, browser_port) do + case guarded_call(fn -> udp_module.send(socket, host, browser_port, <<3>>) end) do + {:ok, :ok} -> + :ok + + {:ok, {:error, reason}} -> + error("SQL Server Browser send failed: #{inspect(reason)}") + + {:ok, other} -> + error("SQL Server Browser send returned an unexpected result: #{inspect(other)}") + + {:error, reason} -> + error("SQL Server Browser send failed: #{inspect(reason)}") + end + end + + defp receive_response(udp_module, socket, timeout) do + case guarded_call(fn -> udp_module.recv(socket, 0, timeout) end) do + {:ok, {:ok, message}} -> + {:ok, message} + + {:ok, {:error, :timeout}} -> + error("SQL Server Browser instance lookup timed out after #{timeout}ms") + + {:ok, {:error, reason}} -> + error("SQL Server Browser receive failed: #{inspect(reason)}") + + {:ok, other} -> + error("SQL Server Browser receive returned an unexpected result: #{inspect(other)}") + + {:error, reason} -> + error("SQL Server Browser receive failed: #{inspect(reason)}") + end + end + + defp close_socket(udp_module, socket) do + _ = guarded_call(fn -> udp_module.close(socket) end) + :ok + end + + defp parse_response( + {_address, source_port, <<5, declared_length::little-16, data::binary>>}, + source_port, + instance + ) + when declared_length == byte_size(data) do + parse_instances(data, instance) + end + + defp parse_response(_message, _browser_port, _instance) do + error("SQL Server Browser returned a malformed response") + end + + defp parse_instances(data, instance) do + if String.valid?(data) do + servers = + data + |> String.trim_trailing(<<0>>) + |> String.split(";;", trim: true) + |> Enum.reduce([], fn record, servers -> + case parse_server(record) do + {:ok, server} -> [server | servers] + :error -> servers + end + end) + + find_instance(servers, instance) + else + error("SQL Server Browser returned a malformed response") + end + end + + defp parse_server(record) do + fields = String.split(record, ";", trim: false) + + if fields != [] and rem(length(fields), 2) == 0 do + fields + |> Enum.chunk_every(2) + |> Enum.reduce_while({:ok, %{}}, fn + [key, value], {:ok, server} -> + key = key |> String.trim() |> String.downcase() + + if key == "" do + {:halt, :error} + else + {:cont, {:ok, Map.put(server, key, String.trim(value))}} + end + + _fields, _server -> + {:halt, :error} + end) + else + :error + end + end + + defp find_instance([], _instance) do + error("SQL Server Browser returned a malformed response") + end + + defp find_instance(servers, instance) do + instance = String.downcase(instance) + + case Enum.find(servers, fn server -> + case Map.get(server, "instancename") do + name when is_binary(name) -> String.downcase(name) == instance + _other -> false + end + end) do + nil -> + error("Instance #{instance} not found") + + server -> + normalize_port(Map.get(server, "tcp"), "SQL Server Browser tcp port") + end + end + + defp instance_name(opts) do + case Keyword.get(opts, :instance) do + instance when is_binary(instance) -> + case String.trim(instance) do + "" -> error("SQL Server instance name must not be empty") + instance -> {:ok, instance} + end + + instance when is_list(instance) -> + instance_name(instance: List.to_string(instance)) + + instance -> + error("SQL Server instance name is invalid: #{inspect(instance)}") + end + rescue + _exception -> error("SQL Server instance name is invalid") + end + + defp instance_timeout(opts) do + case Keyword.get(opts, :instance_timeout, @default_timeout) do + timeout when is_integer(timeout) and timeout >= 0 -> {:ok, timeout} + timeout -> error("SQL Server Browser timeout is invalid: #{inspect(timeout)}") + end + end + + defp browser_port(opts) do + opts + |> Keyword.get(:instance_browser_port, @default_browser_port) + |> normalize_port("SQL Server Browser port") + end + + defp fallback_port(opts) do + case Keyword.fetch(opts, :port) do + {:ok, port} -> + normalize_port(port, "SQL Server fallback port") + + :error -> + case System.get_env("MSSQLPORT") do + nil -> :none + port -> normalize_port(port, "SQL Server fallback port") + end + end + end + + defp normalize_port(port, _label) when is_integer(port) and port in 1..65_535 do + {:ok, port} + end + + defp normalize_port(port, label) when is_binary(port) do + case Integer.parse(String.trim(port)) do + {port, ""} when port in 1..65_535 -> {:ok, port} + _other -> error("#{label} is invalid: #{inspect(port)}") + end + end + + defp normalize_port(port, label) do + error("#{label} is invalid: #{inspect(port)}") + end + + defp guarded_call(fun) do + {:ok, fun.()} + rescue + exception -> {:error, {:exception, exception}} + catch + kind, reason -> {:error, {kind, reason}} + end + + defp error(message), do: {:error, %Tds.Error{message: message}} +end diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex index 2fc918e1..9238b750 100644 --- a/lib/tds/protocol.ex +++ b/lib/tds/protocol.ex @@ -2,7 +2,7 @@ defmodule Tds.Protocol do @moduledoc """ Implements DBConnection behaviour for TDS protocol. """ - alias Tds.{Parameter, Query} + alias Tds.{Instance, Parameter, Query} import Tds.{BinaryUtils, Messages, Utils} require Logger use DBConnection @@ -78,9 +78,20 @@ defmodule Tds.Protocol do connect(opts, s) _instance -> - case instance(opts, s) do - {:ok, s} -> connect(opts, s) - err -> {:error, err} + case Instance.resolve_port(opts) do + {:ok, port} -> + connect(opts, %{s | itcp: port}) + + {:fallback, port, instance_error} -> + Logger.warning( + "SQL Server Browser instance lookup failed; falling back to TCP port #{port}: " <> + Exception.message(instance_error) + ) + + connect(opts, %{s | itcp: port}) + + {:error, error} -> + {:error, error} end end end @@ -335,21 +346,6 @@ defmodule Tds.Protocol do # CONNECTION - defp instance(opts, s) do - host = Keyword.fetch!(opts, :hostname) - host = if is_binary(host), do: String.to_charlist(host), else: host - - case :gen_udp.open(0, [:binary, {:active, false}, {:reuseaddr, true}]) do - {:ok, sock} -> - :gen_udp.send(sock, host, 1434, <<3>>) - {:ok, msg} = :gen_udp.recv(sock, 0) - parse_udp(msg, %{s | opts: opts, usock: sock}) - - {:error, error} -> - {:error, %Tds.Error{message: "udp connect: #{error}"}} - end - end - defp connect(opts, s) do host = Keyword.fetch!(opts, :hostname) host = if is_binary(host), do: String.to_charlist(host), else: host @@ -385,46 +381,6 @@ defmodule Tds.Protocol do end end - defp parse_udp( - {_, 1434, <<_head::binary-3, data::binary>>}, - %{opts: opts, usock: sock} = s - ) do - :gen_udp.close(sock) - - server = - data - |> String.split(";;") - |> Enum.drop(-1) - |> Enum.reduce([], fn str, acc -> - server = - str - |> String.split(";") - |> Enum.chunk_every(2) - |> Enum.reduce([], fn [k, v], acc -> - k = - k - |> String.downcase() - |> String.to_atom() - - Keyword.put_new(acc, k, v) - end) - - [server | acc] - end) - |> Enum.find(fn s -> - String.downcase(s[:instancename]) == String.downcase(opts[:instance]) - end) - - case server do - nil -> - {:error, %Tds.Error{message: "Instance #{opts[:instance]} not found"}} - - serv -> - {port, _} = Integer.parse(serv[:tcp]) - {:ok, %{s | opts: opts, itcp: port, usock: nil}} - end - end - defp ssl_connect(%{sock: {:gen_tcp, sock}, opts: opts} = s) do case Tds.Tls.connect(sock, opts[:ssl_opts] || []) do {:ok, ssl_sock} -> diff --git a/test/instance_test.exs b/test/instance_test.exs new file mode 100644 index 00000000..c1640dbb --- /dev/null +++ b/test/instance_test.exs @@ -0,0 +1,227 @@ +Code.require_file("support/test_udp.ex", __DIR__) + +defmodule Tds.InstanceTest do + use ExUnit.Case, async: true + + alias Tds.Instance + + test "resolves a named instance with the 3000ms default timeout and closes the socket" do + response = browser_response("SQLUTF", 51_433) + Tds.TestUdp.configure(recv: {:ok, {{127, 0, 0, 1}, 1434, response}}) + + assert {:ok, 51_433} = + Instance.resolve( + hostname: "db.internal", + instance: "sqlutf", + instance_udp_module: Tds.TestUdp + ) + + assert_receive {:udp_recv, :test_socket, 0, 3_000} + assert_receive {:udp_close, :test_socket} + end + + test "closes the socket and returns an error when sending fails" do + Tds.TestUdp.configure(send: {:error, :ehostunreach}) + + assert {:error, %Tds.Error{message: message}} = resolve() + assert message =~ "send failed" + refute_receive {:udp_recv, :test_socket, 0, _timeout} + assert_receive {:udp_close, :test_socket} + end + + test "bounds the receive wait with the configured timeout and closes the socket" do + Tds.TestUdp.configure(recv: {:error, :timeout}) + + assert {:error, %Tds.Error{message: message}} = resolve(instance_timeout: 25) + assert message =~ "timed out after 25ms" + assert_receive {:udp_recv, :test_socket, 0, 25} + assert_receive {:udp_close, :test_socket} + end + + test "converts a raised receive error into Tds.Error and closes the socket" do + Tds.TestUdp.configure(recv: {:raise, ArgumentError.exception("bad recv")}) + + assert {:error, %Tds.Error{message: message}} = resolve() + assert message =~ "receive failed" + assert_receive {:udp_close, :test_socket} + end + + test "rejects a malformed browser response without crashing and closes the socket" do + malformed = <<5, 0, 0, "InstanceName;SQLUTF;tcp">> + Tds.TestUdp.configure(recv: {:ok, {{127, 0, 0, 1}, 1434, malformed}}) + + assert {:error, %Tds.Error{message: message}} = resolve() + assert message =~ "malformed response" + assert_receive {:udp_close, :test_socket} + end + + test "rejects a valid-looking payload when the declared response length is wrong" do + <<5, _declared::little-16, data::binary>> = browser_response("SQLUTF", 51_433) + malformed = <<5, byte_size(data) + 1::little-16, data::binary>> + Tds.TestUdp.configure(recv: {:ok, {{127, 0, 0, 1}, 1434, malformed}}) + + assert {:error, %Tds.Error{message: message}} = resolve() + assert message =~ "malformed response" + assert_receive {:udp_close, :test_socket} + end + + test "returns the configured fixed port when instance lookup fails" do + Tds.TestUdp.configure(recv: {:error, :timeout}) + + assert {:fallback, 15_433, %Tds.Error{message: message}} = + Instance.resolve_port( + hostname: "db.internal", + instance: "SQLUTF", + port: "15433", + instance_timeout: 10, + instance_udp_module: Tds.TestUdp + ) + + assert message =~ "timed out after 10ms" + assert_receive {:udp_close, :test_socket} + end + + test "returns the discovery error when no fixed fallback port is configured" do + Tds.TestUdp.configure(recv: {:error, :timeout}) + + assert {:error, %Tds.Error{message: message}} = + Instance.resolve_port( + hostname: "db.internal", + instance: "SQLUTF", + instance_timeout: 10, + instance_udp_module: Tds.TestUdp + ) + + assert message =~ "timed out after 10ms" + assert_receive {:udp_close, :test_socket} + end + + test "handles an udp open error without attempting send or close" do + Tds.TestUdp.configure(open: {:error, :eacces}) + + assert {:error, %Tds.Error{message: message}} = resolve() + assert message =~ "udp open failed" + refute_receive {:udp_send, _socket, _host, _port, _payload} + refute_receive {:udp_close, _socket} + end + + test "a real UDP blackhole bounds elapsed time and falls back to the fixed port" do + {udp_pid, udp_ref, browser_port} = start_udp_blackhole() + started_at = System.monotonic_time(:millisecond) + + assert {:fallback, 15_433, %Tds.Error{message: message}} = + Instance.resolve_port( + hostname: "127.0.0.1", + instance: "SQLUTF", + instance_browser_port: browser_port, + instance_timeout: 30, + port: 15_433 + ) + + elapsed_ms = System.monotonic_time(:millisecond) - started_at + + assert message =~ "timed out after 30ms" + assert elapsed_ms in 20..1_000 + assert_receive {:udp_query_dropped, ^udp_pid, <<3>>} + + send(udp_pid, :stop) + assert_receive {:DOWN, ^udp_ref, :process, ^udp_pid, :normal} + end + + test "Protocol attempts the configured TCP port after a real UDP response loss" do + {udp_pid, udp_ref, browser_port} = start_udp_blackhole() + {tcp_pid, tcp_ref, fallback_port} = start_tcp_probe() + started_at = System.monotonic_time(:millisecond) + + assert {:error, %Tds.Error{}} = + Tds.Protocol.connect( + hostname: "127.0.0.1", + instance: "SQLUTF", + instance_browser_port: browser_port, + instance_timeout: 30, + port: fallback_port, + username: "test-user", + password: "test-password", + timeout: 200 + ) + + elapsed_ms = System.monotonic_time(:millisecond) - started_at + + assert elapsed_ms in 20..1_000 + assert_receive {:udp_query_dropped, ^udp_pid, <<3>>} + assert_receive {:tcp_fallback_connected, ^tcp_pid} + assert_receive {:DOWN, ^tcp_ref, :process, ^tcp_pid, :normal} + + send(udp_pid, :stop) + assert_receive {:DOWN, ^udp_ref, :process, ^udp_pid, :normal} + end + + defp resolve(overrides \\ []) do + Instance.resolve( + Keyword.merge( + [ + hostname: "db.internal", + instance: "SQLUTF", + instance_udp_module: Tds.TestUdp + ], + overrides + ) + ) + end + + defp browser_response(instance, port) do + data = + "ServerName;DB01;InstanceName;#{instance};IsClustered;No;" <> + "Version;16.0.1000.6;tcp;#{port};;" + + <<5, byte_size(data)::little-16, data::binary>> + end + + defp start_udp_blackhole do + parent = self() + + {pid, ref} = + spawn_monitor(fn -> + parent_ref = Process.monitor(parent) + {:ok, socket} = :gen_udp.open(0, [:binary, active: false, reuseaddr: true]) + {:ok, {_address, port}} = :inet.sockname(socket) + send(parent, {:udp_blackhole_ready, self(), port}) + + {:ok, {_peer_address, _peer_port, payload}} = :gen_udp.recv(socket, 0, 1_000) + send(parent, {:udp_query_dropped, self(), payload}) + + receive do + :stop -> :ok + {:DOWN, ^parent_ref, :process, ^parent, _reason} -> :ok + after + 2_000 -> :ok + end + + :gen_udp.close(socket) + end) + + assert_receive {:udp_blackhole_ready, ^pid, port} + {pid, ref, port} + end + + defp start_tcp_probe do + parent = self() + + {pid, ref} = + spawn_monitor(fn -> + {:ok, listener} = + :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + + {:ok, {_address, port}} = :inet.sockname(listener) + send(parent, {:tcp_probe_ready, self(), port}) + + {:ok, socket} = :gen_tcp.accept(listener, 1_000) + send(parent, {:tcp_fallback_connected, self()}) + :gen_tcp.close(socket) + :gen_tcp.close(listener) + end) + + assert_receive {:tcp_probe_ready, ^pid, port} + {pid, ref, port} + end +end diff --git a/test/support/test_udp.ex b/test/support/test_udp.ex new file mode 100644 index 00000000..ad2f35f4 --- /dev/null +++ b/test/support/test_udp.ex @@ -0,0 +1,35 @@ +defmodule Tds.TestUdp do + @moduledoc false + + @config_key {__MODULE__, :config} + + def configure(config), do: Process.put(@config_key, config) + + def open(port, options) do + send(self(), {:udp_open, port, options}) + response(:open, {:ok, :test_socket}) + end + + def send(socket, host, port, payload) do + send(self(), {:udp_send, socket, host, port, payload}) + response(:send, :ok) + end + + def recv(socket, length, timeout) do + send(self(), {:udp_recv, socket, length, timeout}) + response(:recv, {:error, :timeout}) + end + + def close(socket) do + send(self(), {:udp_close, socket}) + response(:close, :ok) + end + + defp response(key, default) do + case Keyword.get(Process.get(@config_key, []), key, default) do + {:raise, exception} -> raise exception + {:throw, reason} -> throw(reason) + response -> response + end + end +end