From 9d6866b7a2000a33e25faa0a4dc62163ec0ced80 Mon Sep 17 00:00:00 2001 From: Ben Youngblood Date: Tue, 16 May 2023 16:17:09 -0700 Subject: [PATCH] Add an option to verify TCP pings This enables user-defined captive portal detection by adding a hook to `VintageNet.Connectivity.TCPPing` so that consumers can perform a custom test against the established TCP connection (such as a TLS handshake). --- README.md | 54 +++++++++++++++ lib/vintage_net/connectivity/host_list.ex | 10 +-- .../connectivity/internet_checker.ex | 2 +- lib/vintage_net/connectivity/tcp_ping.ex | 65 +++++++++++++++++-- .../connectivity/host_list_test.exs | 4 +- .../connectivity/tcp_ping_test.exs | 54 +++++++++++++-- 6 files changed, 167 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 174dcaa4..87af7451 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,60 @@ GenServer to the `:child_specs` configuration returned by the technology. E.g., `child_specs: [{VintageNet.Connectivity.InternetChecker, "eth0"}]`. Most users do not need to be concerned about this. +### Custom connectivity verification + +In certain circumstances, it is possible to resolve DNS and open a TCP +connection to an Internet server when in reality, real traffic will be blocked +or redirected. This can happen when connected to a network that uses a captive +portal server to authenticate clients. + +To aid in detecting these scenarios, VintageNet provides a hook to verify +established TCP connections via the `:internet_host_verify_callback` key in the +application environment. A common use for this is to start a TLS session on the +TCP socket in order to verify a known good certificate. + +The callback function can be specified at runtime as an anonymous function or in +config as a `{module, function}` tuple. The function should conform to the type +`t:VintageNet.Connectivity.TCPPing.verify_fun/0`. + +For example, + +```elixir +defmodule MyConnectivityVerifier do + @timeout 5_000 + + def verify_connection(tcp_socket, ifname, {host, port}) + + # Captive portal detection is not needed on cellular + def verify_connection(_tcp_socket, "wwan0", _address), do: true + + def verify_connection(tcp_socket, _ifname, {host, _port}) do + # This is only a simple example. This function will raise an error if `host` + # is an IP tuple, and it will fail to verify wildcard certificates. + # See `:ssl.connect/3` for a full list of options. + opts = [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: String.to_charlist(host) + ] + + case :ssl.connect(tcp_socket, opts, @timeout) do + {:ok, ssl_socket} -> + _ = :ssl.close(ssl_socket) + true + + _ -> + false + end + end +end + +# config.exs +config :vintage_net, + internet_host_list: [{"abcdefghijk-ats.iot.us-east-1.amazonaws.com", 443}], + internet_host_verify_callback: {MyConnectivityVerifier, :verify_connection} +``` + ## Power Management Some devices require additional work to be done for them to become available. diff --git a/lib/vintage_net/connectivity/host_list.ex b/lib/vintage_net/connectivity/host_list.ex index 345510bf..39b73db1 100644 --- a/lib/vintage_net/connectivity/host_list.ex +++ b/lib/vintage_net/connectivity/host_list.ex @@ -11,7 +11,7 @@ defmodule VintageNet.Connectivity.HostList do @type ip_or_hostname() :: :inet.ip_address() | String.t() @type name_port() :: {ip_or_hostname(), 1..65535} - @type ip_port() :: {:inet.ip_address(), 1..65535} + @type name_ip_port() :: {ip_or_hostname(), :inet.ip_address(), 1..65535} @type hostent() :: record(:hostent, []) @@ -83,7 +83,7 @@ defmodule VintageNet.Connectivity.HostList do should be called again to get another set. This involves DNS, so the call can block. """ - @spec create_ping_list([name_port()]) :: [ip_port()] + @spec create_ping_list([name_port()]) :: [name_ip_port()] def create_ping_list(hosts) do hosts |> Enum.flat_map(&resolve/1) @@ -92,15 +92,15 @@ defmodule VintageNet.Connectivity.HostList do |> Enum.take(3) end - defp resolve({ip, _port} = ip_port) when is_tuple(ip) do - [ip_port] + defp resolve({ip, port} = _ip_port) when is_tuple(ip) do + [{ip, ip, port}] end defp resolve({name, port}) when is_binary(name) do # Only consider IPv4 for now case :inet.gethostbyname(String.to_charlist(name)) do {:ok, hostent(h_addr_list: addresses)} -> - for address <- addresses, do: {address, port} + for address <- addresses, do: {name, address, port} _error -> # DNS not working, so the internet is not working enough diff --git a/lib/vintage_net/connectivity/internet_checker.ex b/lib/vintage_net/connectivity/internet_checker.ex index a59f9408..6dfbdfa1 100644 --- a/lib/vintage_net/connectivity/internet_checker.ex +++ b/lib/vintage_net/connectivity/internet_checker.ex @@ -135,7 +135,7 @@ defmodule VintageNet.Connectivity.InternetChecker do # pinging those addresses would be inconclusive. ping_list = HostList.create_ping_list(state.configured_hosts) - |> Enum.filter(&Inspector.routed_address?(state.ifname, &1)) + |> Enum.filter(&Inspector.routed_address?(state.ifname, elem(&1, 1))) %{state | ping_list: ping_list} end diff --git a/lib/vintage_net/connectivity/tcp_ping.ex b/lib/vintage_net/connectivity/tcp_ping.ex index 353f5cdb..fd96e21f 100644 --- a/lib/vintage_net/connectivity/tcp_ping.ex +++ b/lib/vintage_net/connectivity/tcp_ping.ex @@ -13,7 +13,15 @@ defmodule VintageNet.Connectivity.TCPPing do """ @ping_timeout 5_000 - @type ping_error_reason :: :if_not_found | :no_ipv4_address | :inet.posix() + @type ping_error_reason :: :if_not_found | :no_ipv4_address | :verify_failed | :inet.posix() + + @type ping_target :: + {hostname :: VintageNet.any_ip_address(), address :: VintageNet.any_ip_address(), + port :: non_neg_integer()} + + @type verify_fun :: (:gen_tcp.socket(), VintageNet.ifname(), ping_target() -> boolean()) + + @type verify_callback :: verify_fun() | {module(), atom()} @doc """ Check connectivity with another device @@ -26,17 +34,19 @@ defmodule VintageNet.Connectivity.TCPPing do Source IP-based routing is required for the TCP connect to go out the right network interface. This is configured by default when using VintageNet. """ - @spec ping(VintageNet.ifname(), {VintageNet.any_ip_address(), non_neg_integer()}) :: + @spec ping(VintageNet.ifname(), ping_target(), verify_callback()) :: :ok | {:error, ping_error_reason()} - def ping(ifname, {host, port}) do + def ping( + ifname, + {_hostname, host, _port} = ping_target, + verify_callback \\ Application.get_env(:vintage_net, :internet_host_verify_callback) + ) do # Note: No support for DNS since DNS can't be forced through an # interface. I.e., errors on other interfaces mess up DNS even if the # one of interest is ok. with {:ok, dest_ip} <- VintageNet.IP.ip_to_tuple(host), - {:ok, src_ip} <- get_interface_address(ifname, family(dest_ip)), - {:ok, tcp} <- :gen_tcp.connect(dest_ip, port, [ip: src_ip], @ping_timeout) do - _ = :gen_tcp.close(tcp) - :ok + {:ok, src_ip} <- get_interface_address(ifname, family(dest_ip)) do + connect_and_verify(verify_callback, ifname, ping_target, src_ip, dest_ip) else {:error, :econnrefused} -> # If the remote refuses the connection, then that means that it @@ -80,4 +90,45 @@ defmodule VintageNet.Connectivity.TCPPing do defp family({_, _, _, _}), do: :inet defp family({_, _, _, _, _, _, _, _}), do: :inet6 + + # If no verify callback was given, then just attempt to connect. + defp connect_and_verify(nil, _ifname, {_hostname, _host, port}, src_ip, dest_ip) do + case :gen_tcp.connect(dest_ip, port, [ip: src_ip], @ping_timeout) do + {:ok, tcp} -> + _ = :gen_tcp.close(tcp) + :ok + + {:error, :econnrefused} -> + # If the remote refuses the connection, then that means that it + # received it and we're connected to the internet! + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp connect_and_verify(verify_callback, ifname, {_, _, port} = ping_target, src_ip, dest_ip) do + with {:ok, tcp} <- :gen_tcp.connect(dest_ip, port, [ip: src_ip], @ping_timeout), + true <- do_verify(verify_callback, tcp, ifname, ping_target) do + _ = :gen_tcp.close(tcp) + else + {:error, reason} -> + {:error, reason} + + false -> + {:error, :verify_failed} + + posix_error -> + {:error, posix_error} + end + end + + defp do_verify(fun, tcp_socket, ifname, ping_target) when is_function(fun, 3) do + fun.(tcp_socket, ifname, ping_target) + end + + defp do_verify({module, fun}, tcp_socket, ifname, ping_target) do + apply(module, fun, [tcp_socket, ifname, ping_target]) + end end diff --git a/test/vintage_net/connectivity/host_list_test.exs b/test/vintage_net/connectivity/host_list_test.exs index 0b3d1b6d..b2862a74 100644 --- a/test/vintage_net/connectivity/host_list_test.exs +++ b/test/vintage_net/connectivity/host_list_test.exs @@ -68,13 +68,13 @@ defmodule VintageNet.Connectivity.HostListTest do test "no duplicates" do list = HostList.create_ping_list([{{1, 1, 1, 1}, 1}, {{1, 1, 1, 1}, 1}, {{1, 1, 1, 1}, 1}]) - assert list == [{{1, 1, 1, 1}, 1}] + assert list == [{{1, 1, 1, 1}, {1, 1, 1, 1}, 1}] end test "resolves names" do result = HostList.create_ping_list([{"localhost", 5}]) - assert {{127, 0, 0, 1}, 5} in result + assert {"localhost", {127, 0, 0, 1}, 5} in result end test "removes bad hostnames" do diff --git a/test/vintage_net/connectivity/tcp_ping_test.exs b/test/vintage_net/connectivity/tcp_ping_test.exs index eb219304..e79e775a 100644 --- a/test/vintage_net/connectivity/tcp_ping_test.exs +++ b/test/vintage_net/connectivity/tcp_ping_test.exs @@ -7,8 +7,8 @@ defmodule VintageNet.Connectivity.TCPPingTest do test "ping IPv4 known hosts" do ifname = Utils.get_ifname_for_tests() - assert TCPPing.ping(ifname, {"127.0.0.1", 80}) == :ok - assert TCPPing.ping(ifname, {"1.1.1.1", 53}) == :ok + assert TCPPing.ping(ifname, {"localhost", "127.0.0.1", 80}) == :ok + assert TCPPing.ping(ifname, {"1.1.1.1", "1.1.1.1", 53}) == :ok end # If this fails and your LAN doesn't support IPv6, run "mix test --exclude requires_ipv6" @@ -16,16 +16,16 @@ defmodule VintageNet.Connectivity.TCPPingTest do test "ping IPv6 known hosts" do ifname = Utils.get_ifname_for_tests() - assert TCPPing.ping(ifname, {"::1", 80}) == :ok - assert TCPPing.ping(ifname, {"2606:4700:4700::1111", 53}) == :ok + assert TCPPing.ping(ifname, {"localhost", "::1", 80}) == :ok + assert TCPPing.ping(ifname, {"1.1.1.1", "2606:4700:4700::1111", 53}) == :ok end test "ping internet_host_list" do ifname = Utils.get_ifname_for_tests() # While these won't work for everyone, they should work on CI - for host_port <- Application.fetch_env!(:vintage_net, :internet_host_list) do - assert TCPPing.ping(ifname, host_port) == :ok + for {host, port} <- Application.fetch_env!(:vintage_net, :internet_host_list) do + assert TCPPing.ping(ifname, {"", host, port}) == :ok end end @@ -33,6 +33,46 @@ defmodule VintageNet.Connectivity.TCPPingTest do ifname = Utils.get_ifname_for_tests() # This IP address is in a reserved IP range and shouldn't work - assert TCPPing.ping(ifname, {"192.0.2.254", 80}) == {:error, :timeout} + assert TCPPing.ping(ifname, {"", "192.0.2.254", 80}) == {:error, :timeout} + end + + test "ping with verify callback" do + ifname = Utils.get_ifname_for_tests() + + # for this test, we need to actually be listening on a port + {:ok, socket} = + :gen_tcp.listen(0, [ + :binary, + {:ip, {127, 0, 0, 1}}, + {:packet, :line}, + {:active, false}, + {:reuseaddr, true} + ]) + + {:ok, port} = :inet.port(socket) + + spawn_link(fn -> + {:ok, client} = :gen_tcp.accept(socket, 1000) + :gen_tcp.send(client, "hello world\n") + :gen_tcp.shutdown(client, :read_write) + + {:ok, client} = :gen_tcp.accept(socket, 1000) + :gen_tcp.send(client, "goodbye world\n") + :gen_tcp.shutdown(client, :read_write) + end) + + verify_fun = fn _, _, _ -> + receive do + {:tcp, _port, 'hello world\n'} -> true + _ -> false + end + end + + assert TCPPing.ping(ifname, {"localhost", "127.0.0.1", port}, verify_fun) == :ok + + assert TCPPing.ping(ifname, {"localhost", "127.0.0.1", port}, verify_fun) == + {:error, :verify_failed} + + :gen_tcp.close(socket) end end