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 more sophisticated internet connectivity checker #520

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
768201e
Add SSL based internet connectivity checker
ConnorRigby May 2, 2024
32f3119
Update schema for internet connectivity checker list
ConnorRigby May 2, 2024
f179d2d
styling update
ConnorRigby May 2, 2024
28e74d6
Fix tcp_ping host_list tests
ConnorRigby May 2, 2024
111c294
Add warning about pre-OTP25 support for SSLConnect
ConnorRigby May 2, 2024
9571baa
Allow pluggable ssl opts
ConnorRigby May 3, 2024
fb2722c
Delete .tool-versions-bac
fhunleth May 3, 2024
a3d8a55
Create behaviour for internet connectivity checking
ConnorRigby May 6, 2024
2266f12
Add behaviour for ssl ping allowing users to specify their own implem…
ConnorRigby May 6, 2024
a8df12f
Some ideas
fhunleth May 11, 2024
ce21856
Remove unneeded normalization
fhunleth May 12, 2024
ef88d08
Remove behaviour for SSL Ping connection options
ConnorRigby May 13, 2024
e742c14
Update internet checker to support different connectivity status
ConnorRigby May 14, 2024
9a5b41e
Remove SSL Connectivity test
ConnorRigby May 14, 2024
b58d228
[WIP] Add HTTP connectivity checker
ConnorRigby May 14, 2024
83d1fc1
Add HTTP Connectivity Checker
ConnorRigby May 15, 2024
dba444e
Rename HTTP to WebRequest
ConnorRigby May 15, 2024
b600041
add simple HTTP client to replace httpc
ConnorRigby May 16, 2024
2b7d405
WIP
fhunleth Jun 5, 2024
1d57742
Update connectivity check spec to accept arbitrary property table ent…
ConnorRigby Jun 6, 2024
11cac57
Add WhenWhere connectivity tester module
ConnorRigby Jun 6, 2024
4316685
Fix tests
ConnorRigby Jun 6, 2024
3ffcf6b
Fix hostlist tests
ConnorRigby Jun 7, 2024
1e48505
Fix elixir pre v1.14
ConnorRigby Jun 7, 2024
869345f
don't run whenwhere tests on CI for now
ConnorRigby Jun 7, 2024
64d7fcc
check other whenwhere url
ConnorRigby Jun 7, 2024
80241bf
Add erlang term decoding to whenwhere
ConnorRigby Jun 13, 2024
894d113
replace {:ip, addr} with {:bind_to_device, ifname} in connectivity ch…
ConnorRigby Jun 13, 2024
c60fce3
Cleanup unused code, add whenwhere docs
ConnorRigby Jun 14, 2024
1ba959f
Fix dialyzer and credo
ConnorRigby Jun 14, 2024
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
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ resolvconf | Path to `/etc/resolv.conf`
persistence | Module for persisting network configurations
persistence_dir | Path to a directory for storing persisted configurations
persistence_secret | A 16-byte secret or a function or MFArgs (module, function, arguments tuple) for getting a secret
internet_host_list | IP address or hostnames and ports to try to connect to for checking Internet connectivity. Defaults to a list of large public DNS providers. E.g., `[{{1, 1, 1, 1}, 53}]`.
internet_host_list | IP address or hostnames and ports to try to connect to for checking Internet connectivity. Defaults to a list of large public DNS providers. E.g., `[{:tcp_ping, host: {1, 1, 1, 1}, port: 53}]`.
regulatory_domain | ISO 3166-1 alpha-2 country (`00` for global, `US`, etc.)
additional_name_servers | List of DNS servers to be used in addition to any supplied by an interface. E.g., `[{1, 1, 1, 1}, {8, 8, 8, 8}]`
route_metric_fun | Customize how network interfaces are prioritized. See `VintageNet.Route.DefaultMetric.compute_metric/2`
Expand Down Expand Up @@ -470,10 +470,10 @@ The logic for declaring that the Internet is available is:
2. Get the list of Internet servers to check. See below for the list.
3. Resolve any domain names in the list. If DNS isn't working, remove them from
the list.
4. Pick a random IP address from the remaining list and "ping" it. Technically,
VintageNet tries to connect over TCP to a specified port, and if it either
connects successfully or gets a port closed response, then the device is
Internet-connected.
4. Pick a random method/destination from the remaining list and check it.
Technically, The default method, `:tcp_ping`, tries to connect over TCP to a
specified port, and if it either connects successfully or gets a port closed
response, then the device is Internet-connected.
5. Wait a bit and then go back to step 1.

The list of Internet servers to check is critically important. VintageNet uses
Expand All @@ -482,7 +482,7 @@ default setting has many popular name servers in it. The idea being that if you
can't reach a name server, the Internet probably isn't going to work well.

If you are deploying to locations with locked down networks, you'll find that
the default setting to test name servers won't work. It is not uncommon to find
the default server list won't work. It is not uncommon to find
a network that blocks popular name servers like 8.8.8.8.

The recommendation is to set the `:internet_host_list` to include your backend
Expand All @@ -494,7 +494,7 @@ For example,

```elixir
config :vintage_net,
internet_host_list: [{"abcdefghijk-ats.iot.us-east-1.amazonaws.com", 443}]
internet_host_list: [{:tcp_ping, host: "abcdefghijk-ats.iot.us-east-1.amazonaws.com", port: 443}]
```

The use of the connectivity checker is specified by the technology. Both the
Expand All @@ -504,6 +504,48 @@ 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.

### Alternative internet connectivity checking

The default `:tcp_ping` internet connectivity check can be tricked by some
captive portal software and firewalls. One way to fix it is to authenticate the
connection using TLS with the `:ssl_ping` method as shown here:

```elixir
config :vintage_net,
internet_host_list: [{:ssl_ping, host: "google.com", port: 443}]
```

Custom options may be supplied by passing a mfa in options.

When implementing a connectivity checker, please be aware of the following:

1. The DNS resolver resolves domain names using the current best network
interface. This may not be the network being checked for
internet-connectivity. That means that a DNS issue on another network
interface could prevent a good interface from working.
2. When authenticating servers using TLS, it is important that the time be set
correctly. If the device doesn't have a battery-backed real-time clock and
depends on NTP, it may fail to authentic a remote server thinking that the
server's certificate have expired.
3. It's also important that the CA certificates have not expired or
authentication will fail. IoT devices that sit in a warehouse or don't have
updated certificates may not succeed. It is recommended to specify long
validity certificates (IoT servers typically have these) or verify that the
system CA certificates from `:public_key.cacerts_get/0` that get used are ok.
4. If no internet connectivity check succeeds, the network connection is stuck
in the `:lan` state. Application code can still try to connect to a remote
server and if the connection succeeds, it will start incrementing send and
receive stats to an off-LAN host. That will cause the network interface to
transition to the `:internet` state eventually.

Here's an example of using SSL to a web server that's guaranteed to be on the
Internet:

```elixir
config :vintage_net,
internet_host_list: [{:ssl_ping, host: "internet-server.mycompany.com", port: 443, connect_options_mfa: {MySSLPingConnectOptsImpl, :connect_opts, []}}]
```

## Power Management

Some devices require additional work to be done for them to become available.
Expand Down
65 changes: 65 additions & 0 deletions lib/vintage_net/connectivity/check.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
defmodule VintageNet.Connectivity.Check do
@moduledoc """
Behaviour definition for internet connectivity checking

See `VintageNet.Connectivity.TCPPing` and `VintageNet.Connectivity.SSLPing`
for built-in internet connectivity checkers.

Custom implementations can be used by adding to the `:internet_host_list` option in
the application environment for `:vintage_net`.
"""

@typedoc """
A method and options for checking internet connectivity
"""
@type check_spec() :: {module :: module(), opts :: keyword()}

@typedoc """
Successful result of a connectivity check. Indicates what level
of connectivity is available on an interface.

* First tuple element is a connection status:
* `:lan`
* `:internet`
* Second element is a list of [PropertyTable](https://hexdocs.pm/property_table/) entries

For example if using `wlan0`, returning a check_result of:

{:internet, [{["connection", "public_ip"], {75, 140, 99, 231}}]}

Will result in two properties in the property table:

* `{["interface", "wlan0", "connection"], :internet}`
* `{["interface", "wlan0", "connection", "public_ip"], {75, 140, 99, 231}}`

"""
@type check_result() :: {VintageNet.connection_status(), [{[String.t()], any()}]}

@doc """
Accept/reject a ping spec and normalize any options

This is called at initialization time. If this returns an error, then it
will be removed from the list of internet checkers.
"""
@callback normalize(spec :: check_spec()) :: {:ok, check_spec()} | :error

@doc """
Expand this checker to one that single endpoint checks

It's possible for an internet connectivity checker to have multiple ways of
confirming internet connectivity. This happens when pinging DNS endpoints.
DNS could return zero or more destination IP addresses. If it returns zero
then this checker is definitely going to fail. If it returns more thn one
endpoint, then we want to check them one at a time rather than all at once.
VintageNet will call the `ping/2` callback one at a time based on the results
of this function.
"""
@callback expand(spec :: check_spec()) :: [check_spec()]

@doc """
Perform a check on an interface. the second argument is a keyword argument
list that can have any data supplied via config.
"""
@callback check(ifname :: VintageNet.ifname(), spec :: check_spec()) ::
{:ok, check_result()} | {:error, term()}
end
8 changes: 4 additions & 4 deletions lib/vintage_net/connectivity/check_logic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@ defmodule VintageNet.Connectivity.CheckLogic do
@doc """
Call this when an Internet connectivity check succeeds
"""
@spec check_succeeded(state()) :: state()
def check_succeeded(%{connectivity: :disconnected} = state), do: state
@spec check_succeeded(state(), VintageNet.connection_status()) :: state()
def check_succeeded(%{connectivity: :disconnected} = state, _), do: state

def check_succeeded(state) do
def check_succeeded(state, connectivity) do
# Success - reset the number of strikes to stay in Internet mode
# even if there are hiccups.
%{state | connectivity: :internet, strikes: 0, interval: @max_interval}
%{state | connectivity: connectivity, strikes: 0, interval: @max_interval}
end

@doc """
Expand Down
79 changes: 43 additions & 36 deletions lib/vintage_net/connectivity/host_list.ex
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
defmodule VintageNet.Connectivity.HostList do
@moduledoc false

import Record, only: [defrecord: 2]

alias VintageNet.Connectivity.TCPPing
alias VintageNet.Connectivity.WebRequest
alias VintageNet.Connectivity.WhenWhere
require Logger

@typedoc """
IP address in tuple form or a hostname
"""
@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 option() :: {:host, ip_or_hostname()} | {:port, 1..65535}
@type options() :: [option()]

@type hostent() :: record(:hostent, [])
@type entry() :: {:tcp_ping | :ssl_ping | module(), options()}

defrecord :hostent, Record.extract(:hostent, from_lib: "kernel/include/inet.hrl")
@default_list [{TCPPing, host: {1, 1, 1, 1}, port: 53}]

@doc """
Load the internet host list from the application environment

This function performs basic checks on the list and tries to
help users on easy mistakes.
"""
@spec load(keyword()) :: [name_port()]
@spec load(keyword()) :: [entry()]
def load(config \\ Application.get_all_env(:vintage_net)) do
config_list = internet_host_list(config) ++ legacy_internet_host(config)

hosts =
config_list
|> Enum.map(&normalize/1)
|> Enum.reject(fn x -> x == :error end)
hosts = normalize_all(config_list)

if hosts == [] do
Logger.warning("VintageNet: empty or invalid `:internet_host_list` so using defaults")
[{{1, 1, 1, 1}, 80}]
@default_list
else
hosts
end
Expand All @@ -58,22 +56,39 @@ defmodule VintageNet.Connectivity.HostList do

host ->
Logger.warning(
"VintageNet: :internet_host key is deprecated. Replace with `internet_host_list: [{#{inspect(host)}, 80}]`"
"VintageNet: :internet_host key is deprecated. Replace with `internet_host_list: [{:tcp_ping, host: #{inspect(host)}, port: 80}]`"
)

[{host, 80}]
[{TCPPing, host: host, port: 80}]
end
end

defp normalize({host, port}) when port > 0 and port < 65535 do
case VintageNet.IP.ip_to_tuple(host) do
{:ok, host_as_tuple} -> {host_as_tuple, port}
# Likely a domain name
{:error, _} when is_binary(host) -> {host, port}
_ -> :error
defp normalize_all(list, acc \\ [])

defp normalize_all([entry | rest], acc) do
case normalize(entry) do
{:ok, normalized} -> normalize_all(rest, [normalized | acc])
:error -> normalize_all(rest, acc)
end
end

defp normalize_all([], acc), do: Enum.reverse(acc)

defp normalize({:tcp_ping, opts}), do: normalize({TCPPing, opts})
defp normalize({:web_request, opts}), do: normalize({WebRequest, opts})
defp normalize({:whenwhere, opts}), do: normalize({WhenWhere, opts})

defp normalize({module, opts} = spec) when is_atom(module) and is_list(opts) do
module.normalize(spec)
catch
_, _ -> :error
end

defp normalize({host, port}) when port > 0 and port < 65535 do
# handles legacy list entries, converting them to tcp_ping by default
normalize({TCPPing, host: host, port: port})
end

defp normalize(_), do: :error

@doc """
Expand All @@ -83,29 +98,21 @@ 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([entry]) :: [entry()]
def create_ping_list(hosts) do
hosts
|> Enum.flat_map(&resolve/1)
|> Enum.flat_map(&expand_ping_list/1)
|> Enum.uniq()
|> Enum.shuffle()
|> Enum.take(3)
end

defp resolve({ip, _port} = ip_port) when is_tuple(ip) do
[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}

_error ->
# DNS not working, so the internet is not working enough
# to consider this host
[]
defp expand_ping_list({module, _opts} = spec) do
case module.expand(spec) do
result when is_list(result) -> result
_ -> []
end
catch
_, _ -> []
end
end
Loading