From e34b05ce9c52d9e37b269bb55128c9a7dafb2d82 Mon Sep 17 00:00:00 2001 From: Connor Rigby Date: Thu, 2 May 2024 08:12:19 -0600 Subject: [PATCH] Add SSL based internet connectivity checker This adds the ability to detect sneaky firewalls or anything else that may give false positives on internet connectivity by checking that a valid SSL connection can be made to a list of hosts. Signed-off-by: Connor Rigby --- lib/vintage_net/connectivity/host_list.ex | 26 ++++++++++ .../connectivity/internet_checker.ex | 35 ++++++++++++-- lib/vintage_net/connectivity/ssl_connect.ex | 47 +++++++++++++++++++ lib/vintage_net/connectivity/tcp_ping.ex | 11 ++++- mix.exs | 5 +- .../connectivity/ssl_connect_test.exs | 14 ++++++ 6 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 lib/vintage_net/connectivity/ssl_connect.ex create mode 100644 test/vintage_net/connectivity/ssl_connect_test.exs diff --git a/lib/vintage_net/connectivity/host_list.ex b/lib/vintage_net/connectivity/host_list.ex index 345510bf..c736a450 100644 --- a/lib/vintage_net/connectivity/host_list.ex +++ b/lib/vintage_net/connectivity/host_list.ex @@ -40,6 +40,32 @@ defmodule VintageNet.Connectivity.HostList do end end + @doc """ + Load the internet hostname list from the application environment + """ + @spec load_hostnames(keyword()) :: [{String.t(), 1..65535}] + def load_hostnames(config \\ Application.get_all_env(:vintage_net)) do + config_list = internet_hostname_list(config) + + if config_list == [] do + Logger.warning("VintageNet: empty or invalid `:internet_host_list` so using defaults") + [{"google.com", 443}] + else + config_list + end + end + + defp internet_hostname_list(config) do + case config[:internet_hostname_list] do + host_list when is_list(host_list) -> + host_list + + _ -> + Logger.warning("VintageNet: :internet_hostname_list must be a list") + [] + end + end + defp internet_host_list(config) do case config[:internet_host_list] do host_list when is_list(host_list) -> diff --git a/lib/vintage_net/connectivity/internet_checker.ex b/lib/vintage_net/connectivity/internet_checker.ex index a59f9408..c3beae16 100644 --- a/lib/vintage_net/connectivity/internet_checker.ex +++ b/lib/vintage_net/connectivity/internet_checker.ex @@ -9,15 +9,23 @@ defmodule VintageNet.Connectivity.InternetChecker do """ use GenServer - alias VintageNet.Connectivity.{CheckLogic, HostList, Inspector, TCPPing} + alias VintageNet.Connectivity.{ + CheckLogic, + HostList, + Inspector, + SSLConnect, + TCPPing + } + alias VintageNet.RouteManager require Logger @typedoc false @type state() :: %{ ifname: VintageNet.ifname(), - configured_hosts: [{VintageNet.any_ip_address(), non_neg_integer()}], - ping_list: [{:inet.ip_address(), non_neg_integer()}], + configured_hosts: [{VintageNet.any_ip_address(), 1..65535}], + ping_list: [{:inet.ip_address(), 1..65535}], + hostname_list: [{String.t(), 1..65535}], check_logic: CheckLogic.state(), inspector: Inspector.cache(), status: Inspector.status() @@ -39,6 +47,7 @@ defmodule VintageNet.Connectivity.InternetChecker do ifname: ifname, configured_hosts: HostList.load(), ping_list: [], + hostname_list: [], check_logic: CheckLogic.init(connectivity), inspector: %{}, status: :unknown @@ -111,12 +120,14 @@ defmodule VintageNet.Connectivity.InternetChecker do # 1. Reset status to unknown # 2. See if we can determine internet-connectivity via TCP stats # 3. If still unknown, refresh the ping list - # 4. If still unknown, ping. This step is definitive. + # 4a. If still unknown, ping. This step can be tricked by really stubborn firewalls. + # 4b. If still unknown, connect via SSL. This step cannot be fooled. # 5. Record whether there's internet state |> reset_status() |> check_inspector() |> reload_ping_list() + |> reload_hostname_list() |> ping_if_unknown() |> update_check_logic() end @@ -142,6 +153,13 @@ defmodule VintageNet.Connectivity.InternetChecker do defp reload_ping_list(state), do: state + defp reload_hostname_list(%{status: :unknown, hostname_list: []} = state) do + hostname_list = HostList.load_hostnames() + %{state | hostname_list: hostname_list} + end + + defp reload_hostname_list(state), do: state + defp ping_if_unknown(%{status: :unknown, ping_list: [who | rest]} = state) do case TCPPing.ping(state.ifname, who) do :ok -> %{state | status: :internet} @@ -149,7 +167,14 @@ defmodule VintageNet.Connectivity.InternetChecker do end end - defp ping_if_unknown(%{status: :unknown, ping_list: []} = state) do + defp ping_if_unknown(%{status: :unknown, ping_list: [], hostname_list: [who | rest]} = state) do + case SSLConnect.connect(state.ifname, who) do + :ok -> %{state | status: :internet} + _error -> %{state | status: :no_internet, hostname_list: rest} + end + end + + defp ping_if_unknown(%{status: :unknown, ping_list: [], hostname_list: []} = state) do # Ping list being empty is due to the user only providing hostnames and # DNS resolution not working. %{state | status: :no_internet} diff --git a/lib/vintage_net/connectivity/ssl_connect.ex b/lib/vintage_net/connectivity/ssl_connect.ex new file mode 100644 index 00000000..bdd705d9 --- /dev/null +++ b/lib/vintage_net/connectivity/ssl_connect.ex @@ -0,0 +1,47 @@ +defmodule VintageNet.Connectivity.SSLConnect do + @moduledoc """ + Test connectivity by making a connection using SSL + + Connectivity with a remote host can be checked by making a SSL connection to + it. The connection either works, the connection is refused, or it times out. + The first two cases indicate connectivity. + """ + + import VintageNet.Connectivity.TCPPing, only: [get_interface_address: 2] + + @connect_timeout 5_000 + + @doc """ + Check connectivity with another device + + The "connect" is a SSL connection attempt from the specified interface to + an IP address and port. Failures to connect don't necessarily mean that the + Internet is down, but it's likely especially if the server that's specified + in the configuration is highly available. + """ + @spec connect(VintageNet.ifname(), {String.t(), port}) :: :ok | {:error, :inet.posix()} + def connect(ifname, {hostname, port}) do + with {:ok, src_ip} <- get_interface_address(ifname, :inet), + {:ok, ssl} <- + :ssl.connect( + to_charlist(hostname), + port, + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + active: false, + ip: src_ip + ], + @connect_timeout + ) do + _ = :ssl.close(ssl) + :ok + else + {:error, reason} -> + {:error, reason} + + posix_error -> + {:error, posix_error} + end + end +end diff --git a/lib/vintage_net/connectivity/tcp_ping.ex b/lib/vintage_net/connectivity/tcp_ping.ex index 353f5cdb..31519d31 100644 --- a/lib/vintage_net/connectivity/tcp_ping.ex +++ b/lib/vintage_net/connectivity/tcp_ping.ex @@ -13,7 +13,7 @@ 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_suitable_ip_address | :inet.posix() @doc """ Check connectivity with another device @@ -51,7 +51,14 @@ defmodule VintageNet.Connectivity.TCPPing do end end - defp get_interface_address(ifname, family) do + @doc """ + Helper function for getting an ip address attached to an interface for use with the + `ip` keyword argument for :gen_tcp.connect/3 and related functions. + """ + @spec get_interface_address(VintageNet.ifname(), :inet | :inet6) :: + {:ok, VintageNet.any_ip_address()} + | {:error, :inet.posix() | :if_not_found | :no_suitable_ip_address} + def get_interface_address(ifname, family) do with {:ok, addresses} <- :inet.getifaddrs(), {:ok, params} <- find_addresses_on_interface(addresses, ifname) do find_ip_addr(params, family) diff --git a/mix.exs b/mix.exs index dd3947bf..cf795114 100644 --- a/mix.exs +++ b/mix.exs @@ -57,6 +57,9 @@ defmodule VintageNet.MixProject do # Neustar {{156, 154, 70, 5}, 53} ], + internet_hostname_list: [ + {"google.com", 443} + ], regulatory_domain: "00", # Contain processes in cgroups by setting to: # [cgroup_base: "vintage_net", cgroup_controllers: ["cpu"]] @@ -64,7 +67,7 @@ defmodule VintageNet.MixProject do power_managers: [], route_metric_fun: &VintageNet.Route.DefaultMetric.compute_metric/2 ], - extra_applications: [:logger, :crypto], + extra_applications: [:logger, :crypto, :ssl, :public_key], mod: {VintageNet.Application, []} ] end diff --git a/test/vintage_net/connectivity/ssl_connect_test.exs b/test/vintage_net/connectivity/ssl_connect_test.exs new file mode 100644 index 00000000..d0b08244 --- /dev/null +++ b/test/vintage_net/connectivity/ssl_connect_test.exs @@ -0,0 +1,14 @@ +defmodule VintageNet.Connectivity.SSLConnectTest do + use ExUnit.Case, async: true + + alias VintageNet.Connectivity.SSLConnect + alias VintageNetTest.Utils + + test "connect to known host" do + ifname = Utils.get_ifname_for_tests() + + assert SSLConnect.connect(ifname, {"google.com", 443}) == :ok + assert SSLConnect.connect(ifname, {"github.com", 443}) == :ok + assert SSLConnect.connect(ifname, {"superfakedomain", 443}) == {:error, :nxdomain} + end +end