lib/vintage_net/connectivity/inspector.ex

defmodule VintageNet.Connectivity.Inspector do
  @moduledoc """
  This module looks at the network activity of all TCP socket connections known
  to Erlang/OTP to deduce whether the internet is working.

  To use it, call `check_internet/2`, save the returned cache, and then call it
  again a minute later (or so). If any socket has transferred data in both
  directions to an off-LAN host on the interface of interest, then it will
  return that the internet is available.

  This has a couple advantages:

  1. No data is sent to perform the check which is good for metered connections
  2. Most long-lived TCP connections have a keepalive mechanism that generates
     traffic, so this piggy-backs off that existing connectivity check.
  3. Devices can be behind very restrictive firewalls and internet connectivity
     can still be verified without knowing which IP/port/protocol combinations
     are allowed.

  It is not perfect:

  1. It only works on long-lived TCP connections.
  2. The TCP connection must be sending and receiving data. If the keapalive is
     longer than the `check_internet/2`
  3. It doesn't help if nobody is using the network interface.
  4. It may have scalability issues if there are a LOT of TCP sockets.
  """

  @typedoc """
  Cache for use between `check_internet/2` calls. Initialize to an empty map.
  """
  @type cache() :: %{port() => {non_neg_integer(), non_neg_integer()}}

  @typedoc """
  Internet connectivity status

    * `:internet` - the internet is available
    * `:unknown` - not sure
    * `:no_internet` - the internet is definitely not available
  """
  @type status() :: :internet | :unknown | :no_internet

  @typedoc """
  The return tuple for `check_internet/2`

  * `:internet` - at least one TCP connection sent and received data to a
    non-LAN IP address
  * `:unknown` - no conclusion could be made
  * `:no_internet` - the interface didn't have an IP address, so Internet is
    definitely not available

  Save the cache away and pass it to the next call to `check_internet/2`.
  """
  @type result() :: {status(), cache()}

  @typep ip_address_and_mask() :: {:inet.ip_address(), :inet.ip_address()}

  @doc """
  Check whether the internet is accessible on the specified interface

  Pass an empty map for the cache parameter for the first call. Then pass it
  back the returned cache for each subsequent call. If any TCP socket that's
  connected to a computer on another subnet and that's using the passed in
  network interface has send AND received data since the previous call, then
  `:internet` is returned. If not, then usually `:unknown` is returned to
  signify that internet may be available, but we just don't know. If the
  interface doesn't have an IP address, then `:no_internet` is returned, since
  that's a prerequisite to communicating with anyone on the internet.
  """
  @spec check_internet(VintageNet.ifname(), cache()) :: result()
  def check_internet(ifname, cache) do
    case get_addresses(ifname) do
      [] ->
        # If we don't even have an IP address, then there's no Internet for sure.
        {:no_internet, %{}}

      our_addresses ->
        {:unknown, %{}}
        |> check_ports(Port.list(), our_addresses, cache)
        |> check_sockets(:socket.which_sockets(:tcp), our_addresses, cache)
    end
  end

  @doc false
  @spec check_ports(result(), [port()], [ip_address_and_mask()], cache()) :: result()
  def check_ports(result, [], _our_addresses, _cache), do: result

  def check_ports(result, [socket | rest], our_addresses, cache) do
    new_result =
      case Map.fetch(cache, socket) do
        {:ok, previous_stats} ->
          new_stats = get_port_stats(socket)
          update_result(result, socket, previous_stats, new_stats)

        _ ->
          check_new_port(socket, our_addresses, result)
      end

    check_ports(new_result, rest, our_addresses, cache)
  end

  defp get_port_stats(socket) when is_port(socket) do
    case :inet.getstat(socket, [:send_oct, :recv_oct]) do
      {:ok, [send_oct: tx, recv_oct: rx]} ->
        {tx, rx}

      {:ok, [recv_oct: rx, send_oct: tx]} ->
        {tx, rx}

      {:error, _} ->
        # Race condition. Socket was in the list, but by the time it was
        # checked, it was closed. No big deal. It will be removed from the
        # cache next time. Return bogus values that definitely won't update the
        # result to indicate Internet availability.
        {0, 0}
    end
  end

  @doc false
  @spec check_sockets(result(), [:socket.socket()], [ip_address_and_mask()], cache()) :: result()
  def check_sockets(result, [], _our_addresses, _cache), do: result

  def check_sockets(result, [socket | rest], our_addresses, cache) do
    new_result =
      case Map.fetch(cache, socket) do
        {:ok, previous_stats} ->
          new_stats = get_socket_stats(socket)
          update_result(result, socket, previous_stats, new_stats)

        _ ->
          check_new_socket(socket, our_addresses, result)
      end

    check_sockets(new_result, rest, our_addresses, cache)
  end

  defp get_socket_stats(socket) do
    # Socket API
    %{counters: %{write_byte: tx, read_byte: rx}} = :socket.info(socket)
    {tx, rx}
  end

  defp update_result({:unknown, cache}, socket, {tx1, rx1}, {tx2, rx2} = new_stats)
       when tx2 > tx1 and rx2 > rx1 do
    {:internet, Map.put(cache, socket, new_stats)}
  end

  defp update_result({status, cache}, socket, _previous_stats, new_stats) do
    {status, Map.put(cache, socket, new_stats)}
  end

  defp check_new_port(socket, our_addresses, {status, cache}) do
    with {:name, 'tcp_inet'} <- Port.info(socket, :name),
         true <- connected?(socket),
         {:ok, {src_ip, _src_port}} <- :inet.sockname(socket),
         true <- on_interface?(src_ip, our_addresses),
         {:ok, {dest_ip, _dest_port}} <- :inet.peername(socket),
         false <- on_interface?(dest_ip, our_addresses) do
      {status, Map.put(cache, socket, get_port_stats(socket))}
    else
      _ -> {status, cache}
    end
  end

  defp connected?(socket) do
    case :prim_inet.getstatus(socket) do
      {:ok, status} -> :connected in status
      _ -> false
    end
  end

  defp check_new_socket(socket, our_addresses, {status, cache}) do
    # Socket API
    with %{protocol: :tcp, counters: %{write_byte: tx, read_byte: rx}} <- :socket.info(socket),
         {:ok, %{addr: src_ip}} <- :socket.sockname(socket),
         true <- on_interface?(src_ip, our_addresses),
         {:ok, %{addr: dest_ip}} <- :socket.peername(socket),
         false <- on_interface?(dest_ip, our_addresses) do
      {status, Map.put(cache, socket, {tx, rx})}
    else
      _ -> {status, cache}
    end
  end

  @doc """
  Return true if an IP address is on one of the subnets in a list
  """
  @spec on_interface?(:inet.ip_address(), [ip_address_and_mask()]) :: boolean
  def on_interface?(_ip, []), do: false

  def on_interface?(ip, [one_address | rest]) do
    on_subnet?(ip, one_address) || on_interface?(ip, rest)
  end

  @doc """
  Return true if an IP address is in the subnet

  ## Examples

      iex> Inspector.on_subnet?({192,168,0,50}, {{192,168,0,1}, {255,255,255,0}})
      true

      iex> Inspector.on_subnet?({192,168,5,1}, {{192,168,0,1}, {255,255,255,0}})
      false
  """
  @spec on_subnet?(:inet.ip_address(), ip_address_and_mask()) :: boolean
  def on_subnet?({a, b, c, d}, {{sa, sb, sc, sd}, {ma, mb, mc, md}}) do
    :erlang.band(:erlang.bxor(a, sa), ma) == 0 and
      :erlang.band(:erlang.bxor(b, sb), mb) == 0 and
      :erlang.band(:erlang.bxor(c, sc), mc) == 0 and
      :erlang.band(:erlang.bxor(d, sd), md) == 0
  end

  def on_subnet?(
        {a, b, c, d, e, f, g, h},
        {{sa, sb, sc, sd, se, sf, sg, sh}, {ma, mb, mc, md, me, mf, mg, mh}}
      ) do
    :erlang.band(:erlang.bxor(a, sa), ma) == 0 and
      :erlang.band(:erlang.bxor(b, sb), mb) == 0 and
      :erlang.band(:erlang.bxor(c, sc), mc) == 0 and
      :erlang.band(:erlang.bxor(d, sd), md) == 0 and
      :erlang.band(:erlang.bxor(e, se), me) == 0 and
      :erlang.band(:erlang.bxor(f, sf), mf) == 0 and
      :erlang.band(:erlang.bxor(g, sg), mg) == 0 and
      :erlang.band(:erlang.bxor(h, sh), mh) == 0
  end

  def on_subnet?(_ip, {_subnet_ip, _subnet_mask}) do
    false
  end

  @doc false
  @spec get_addresses(VintageNet.ifname()) :: [ip_address_and_mask()]
  def get_addresses(ifname) do
    with {:ok, interfaces} <- :inet.getifaddrs(),
         {_, info} <- List.keyfind(interfaces, to_charlist(ifname), 0, []) do
      extract_addr_mask(info, [])
    else
      _ ->
        []
    end
  end

  defp extract_addr_mask([], acc), do: acc

  defp extract_addr_mask([{:addr, a}, {:netmask, m} | rest], acc),
    do: extract_addr_mask(rest, [{a, m} | acc])

  defp extract_addr_mask([_ | rest], acc), do: extract_addr_mask(rest, acc)
end