Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an option to verify TCP pings #465

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, ip, port})

# Captive portal detection is not needed on cellular
def verify_connection(_tcp_socket, "wwan0", _target), do: true

def verify_connection(tcp_socket, _ifname, {host, ip, _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.
Expand Down
10 changes: 5 additions & 5 deletions lib/vintage_net/connectivity/host_list.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change seems kind of ugly, but it was necessary in order to preserve the original hostname for custom verify funs to use for SNI.


@type hostent() :: record(:hostent, [])

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/vintage_net/connectivity/internet_checker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 58 additions & 7 deletions lib/vintage_net/connectivity/tcp_ping.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions test/vintage_net/connectivity/host_list_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 47 additions & 7 deletions test/vintage_net/connectivity/tcp_ping_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,72 @@ 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"
@tag :requires_ipv6
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

test "ping IP addresses that shouldn't work" 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