lib/vintage_net/ip/ipv4_config.ex

defmodule VintageNet.IP.IPv4Config do
  @moduledoc """
  This is a helper module for VintageNet.Technology implementations that use
  IPv4.

  IPv4 configuration is specified under the `:ipv4` key in the configuration map.
  Fields include:

  * `:method` - `:dhcp`, `:static`, or `:disabled`

  The `:dhcp` method currently has no additional fields.

  The `:static` method uses the following fields:

  * `:address` - the IP address
  * `:prefix_length` - the number of bits in the IP address to use for the subnet (e.g., 24)
  * `:netmask` - either this or `prefix_length` is used to determine the subnet. If you
    have a choice, use `prefix_length`
  * `:gateway` - the default gateway for this interface (optional)
  * `:name_servers` - a list of DNS servers (optional)
  * `:domain` - DNS search domain (optional)

  Configuration normalization converts `:netmask` to `:prefix_length`.
  """

  alias VintageNet.Interface.RawConfig
  alias VintageNet.{Command, IP}

  @doc """
  Normalize the IPv4 parameters in a configuration.
  """
  @spec normalize(map()) :: %{ipv4: map()}
  def normalize(%{ipv4: ipv4} = config) do
    new_ipv4 = normalize_by_method(ipv4)
    %{config | ipv4: new_ipv4}
  end

  def normalize(config) do
    # No IPv4 configuration, so default to DHCP
    Map.put(config, :ipv4, %{method: :dhcp})
  end

  defp normalize_by_method(%{method: :dhcp}), do: %{method: :dhcp}
  defp normalize_by_method(%{method: :disabled}), do: %{method: :disabled}

  defp normalize_by_method(%{method: :static} = ipv4) do
    new_prefix_length = get_prefix_length(ipv4)

    ipv4
    |> normalize_address()
    |> Map.put(:prefix_length, new_prefix_length)
    |> normalize_gateway()
    |> normalize_name_servers()
    |> Map.take([
      :method,
      :address,
      :prefix_length,
      :gateway,
      :domain,
      :name_servers
    ])
  end

  defp normalize_by_method(_other) do
    raise ArgumentError, "specify an IPv4 address method (:disabled, :dhcp, or :static)"
  end

  defp normalize_address(%{address: address} = config),
    do: %{config | address: IP.ip_to_tuple!(address)}

  defp normalize_address(_config),
    do: raise(ArgumentError, "IPv4 :address key missing in static config")

  defp normalize_gateway(%{gateway: gateway} = config),
    do: %{config | gateway: IP.ip_to_tuple!(gateway)}

  defp normalize_gateway(config), do: config

  defp normalize_name_servers(%{name_servers: servers} = config) when is_list(servers) do
    %{config | name_servers: Enum.map(servers, &IP.ip_to_tuple!/1)}
  end

  defp normalize_name_servers(%{name_servers: one_server} = config) do
    %{config | name_servers: [IP.ip_to_tuple!(one_server)]}
  end

  defp normalize_name_servers(config), do: config

  defp get_prefix_length(%{prefix_length: prefix_length}), do: prefix_length

  defp get_prefix_length(%{netmask: mask}) do
    with {:ok, mask_as_tuple} <- IP.ip_to_tuple(mask),
         {:ok, prefix_length} <- IP.subnet_mask_to_prefix_length(mask_as_tuple) do
      prefix_length
    else
      {:error, _reason} ->
        raise ArgumentError, "invalid subnet mask #{inspect(mask)}"
    end
  end

  defp get_prefix_length(_unspecified),
    do: raise(ArgumentError, "specify :prefix_length or :netmask")

  @doc """
  Add IPv4 configuration commands for supporting static and dynamic IP addressing
  """
  @spec add_config(RawConfig.t(), map(), keyword()) :: RawConfig.t()
  def add_config(
        %RawConfig{
          ifname: ifname,
          up_cmds: up_cmds,
          down_cmds: down_cmds
        } = raw_config,
        %{ipv4: %{method: :disabled}},
        _opts
      ) do
    # Even though IPv4 is disabled, the interface is still brought up
    new_up_cmds = up_cmds ++ [{:run, "ip", ["link", "set", ifname, "up"]}]

    new_down_cmds =
      down_cmds ++
        [
          {:run_ignore_errors, "ip", ["addr", "flush", "dev", ifname, "label", ifname]},
          {:run, "ip", ["link", "set", ifname, "down"]}
        ]

    %RawConfig{
      raw_config
      | up_cmds: new_up_cmds,
        down_cmds: new_down_cmds
    }
  end

  def add_config(
        %RawConfig{
          ifname: ifname,
          child_specs: child_specs,
          up_cmds: up_cmds,
          down_cmds: down_cmds
        } = raw_config,
        %{ipv4: %{method: :dhcp}} = config,
        _opts
      ) do
    new_up_cmds = up_cmds ++ [{:run, "ip", ["link", "set", ifname, "up"]}]

    new_down_cmds =
      down_cmds ++
        [
          {:run_ignore_errors, "ip", ["addr", "flush", "dev", ifname, "label", ifname]},
          {:run, "ip", ["link", "set", ifname, "down"]}
        ]

    hostname = config[:hostname] || get_hostname()

    new_child_specs =
      child_specs ++
        [
          Supervisor.child_spec(
            {VintageNet.Interface.IfupDaemon,
             [
               ifname: ifname,
               command: "udhcpc",
               args: [
                 "-f",
                 "-i",
                 ifname,
                 "-x",
                 "hostname:#{hostname}",
                 "-s",
                 BEAMNotify.bin_path()
               ],
               opts:
                 Command.add_muon_options(
                   stderr_to_stdout: true,
                   log_output: :debug,
                   log_prefix: "udhcpc(#{ifname}): ",
                   env: BEAMNotify.env(name: "vintage_net_comm", report_env: true)
                 )
             ]},
            id: :udhcpc
          ),
          {VintageNet.Connectivity.InternetChecker, ifname}
        ]

    %RawConfig{
      raw_config
      | up_cmds: new_up_cmds,
        down_cmds: new_down_cmds,
        child_specs: new_child_specs
    }
  end

  def add_config(
        %RawConfig{
          ifname: ifname,
          up_cmds: up_cmds,
          down_cmds: down_cmds,
          child_specs: child_specs
        } = raw_config,
        %{ipv4: %{method: :static} = ipv4},
        _opts
      ) do
    addr_subnet = IP.cidr_to_string(ipv4.address, ipv4.prefix_length)

    route_manager_up =
      case ipv4[:gateway] do
        nil ->
          {:fun, VintageNet.RouteManager, :clear_route, [ifname]}

        gateway ->
          {:fun, VintageNet.RouteManager, :set_route,
           [ifname, [{ipv4.address, ipv4.prefix_length}], gateway]}
      end

    resolver_up =
      case ipv4[:name_servers] do
        nil -> {:fun, VintageNet.NameResolver, :clear, [ifname]}
        [] -> {:fun, VintageNet.NameResolver, :clear, [ifname]}
        servers -> {:fun, VintageNet.NameResolver, :setup, [ifname, ipv4[:domain], servers]}
      end

    new_up_cmds =
      up_cmds ++
        [
          {:run_ignore_errors, "ip", ["addr", "flush", "dev", ifname, "label", ifname]},
          {:run, "ip", ["addr", "add", addr_subnet, "dev", ifname, "label", ifname]},
          {:run, "ip", ["link", "set", ifname, "up"]},
          route_manager_up,
          resolver_up
        ]

    new_down_cmds =
      down_cmds ++
        [
          {:fun, VintageNet.RouteManager, :clear_route, [ifname]},
          {:fun, VintageNet.NameResolver, :clear, [ifname]},
          {:run_ignore_errors, "ip", ["addr", "flush", "dev", ifname, "label", ifname]},
          {:run, "ip", ["link", "set", ifname, "down"]}
        ]

    # If there's a default gateway, then check for internet connectivity.
    checker =
      case ipv4[:gateway] do
        nil -> {VintageNet.Connectivity.LANChecker, ifname}
        _exists -> {VintageNet.Connectivity.InternetChecker, ifname}
      end

    new_child_specs = child_specs ++ [checker]

    %RawConfig{
      raw_config
      | up_cmds: new_up_cmds,
        down_cmds: new_down_cmds,
        child_specs: new_child_specs
    }
  end

  defp get_hostname() do
    {:ok, hostname} = :inet.gethostname()
    to_string(hostname)
  end
end