Skip to content

Commit

Permalink
Add an option to verify TCP pings
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
bjyoungblood committed May 17, 2023
1 parent 30835bd commit b3e68c8
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 22 deletions.
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}

@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

0 comments on commit b3e68c8

Please sign in to comment.