lib/vintage_net/ip/dhcpd_config.ex

defmodule VintageNet.IP.DhcpdConfig do
  @moduledoc """
  This is a helper module for VintageNet.Technology implementations that use
  a DHCP server.

  DHCP server parameters are:

  * `:start` - Start of the lease block
  * `:end` - End of the lease block
  * `:max_leases` - The maximum number of leases
  * `:decline_time` - The amount of time that an IP will be reserved (leased to nobody)
  * `:conflict_time` -The amount of time that an IP will be reserved
  * `:offer_time` - How long an offered address is reserved (seconds)
  * `:min_lease` - If client asks for lease below this value, it will be rounded up to this value (seconds)
  * `:auto_time` - The time period at which udhcpd will write out leases file.
  * `:static_leases` - list of `{mac_address, ip_address}`
  * `:options` - a map DHCP response options to set. See below.

  DHCP response options are (see RFC 2132 for details):

  * `:dns` - IP_LIST
  * `:domain` -  STRING - [0x0f] client's domain suffix
  * `:hostname` - STRING
  * `:mtu` - NUM
  * `:router` - IP_LIST
  * `:search` - STRING_LIST - [0x77] search domains
  * `:serverid` - IP (defaults to the interface's IP address)
  * `:subnet` - IP

  Options may also be passed in as integers. These are passed directly to the DHCP server
  and their values are strings that are not interpreted by VintageNet. Use this to support
  custom DHCP header options.
  """

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

  @ip_list_options [:dns, :router]
  @ip_options [:serverid, :subnet]
  @int_options [:mtu]
  @string_options [:hostname, :domain]
  @string_list_options [:search]
  @list_options @ip_list_options ++ @string_list_options

  @doc """
  Normalize the DHCPD parameters in a configuration.
  """
  @spec normalize(map()) :: map()
  def normalize(%{dhcpd: dhcpd} = config) do
    # Normalize IP addresses
    new_dhcpd =
      dhcpd
      |> Map.update(:start, {192, 168, 0, 20}, &IP.ip_to_tuple!/1)
      |> Map.update(:end, {192, 168, 0, 254}, &IP.ip_to_tuple!/1)
      |> normalize_static_leases()
      |> normalize_options()
      |> Map.take([
        :start,
        :end,
        :max_leases,
        :decline_time,
        :conflict_time,
        :offer_time,
        :min_lease,
        :auto_time,
        :static_leases,
        :options
      ])

    %{config | dhcpd: new_dhcpd}
  end

  def normalize(config), do: config

  defp normalize_static_leases(%{static_leases: leases} = dhcpd_config) do
    new_leases = Enum.map(leases, &normalize_lease/1)
    %{dhcpd_config | static_leases: new_leases}
  end

  defp normalize_static_leases(dhcpd_config), do: dhcpd_config

  defp normalize_lease({hwaddr, ipa}) do
    {hwaddr, IP.ip_to_tuple!(ipa)}
  end

  defp normalize_options(%{options: options} = dhcpd_config) do
    new_options = for option <- options, into: %{}, do: normalize_option(option)
    %{dhcpd_config | options: new_options}
  end

  defp normalize_options(dhcpd_config), do: dhcpd_config

  defp normalize_option({ip_option, ip})
       when ip_option in @ip_options do
    {ip_option, IP.ip_to_tuple!(ip)}
  end

  defp normalize_option({ip_list_option, ip_list})
       when ip_list_option in @ip_list_options and is_list(ip_list) do
    {ip_list_option, Enum.map(ip_list, &IP.ip_to_tuple!/1)}
  end

  defp normalize_option({string_list_option, string_list})
       when string_list_option in @string_list_options and is_list(string_list) do
    {string_list_option, Enum.map(string_list, &to_string/1)}
  end

  defp normalize_option({list_option, one_item})
       when list_option in @list_options and not is_list(one_item) do
    # Fix super-easy mistake of not passing a list when there's only one item
    normalize_option({list_option, [one_item]})
  end

  defp normalize_option({int_option, value})
       when int_option in @int_options and
              is_integer(value) do
    {int_option, value}
  end

  defp normalize_option({string_option, string})
       when string_option in @string_options do
    {string_option, to_string(string)}
  end

  defp normalize_option({other_option, string})
       when is_integer(other_option) and is_binary(string) do
    {other_option, to_string(string)}
  end

  defp normalize_option({bad_option, _value}) do
    raise ArgumentError,
          "Unknown dhcpd option '#{bad_option}'. Options unknown to VintageNet can be passed in as integers."
  end

  @doc """
  Add udhcpd configuration commands for running a DHCP server
  """
  @spec add_config(RawConfig.t(), map(), keyword()) :: RawConfig.t()
  def add_config(
        %RawConfig{
          ifname: ifname,
          files: files,
          child_specs: child_specs
        } = raw_config,
        %{dhcpd: dhcpd_config},
        opts
      ) do
    tmpdir = Keyword.fetch!(opts, :tmpdir)
    udhcpd_conf_path = Path.join(tmpdir, "udhcpd.conf.#{ifname}")

    new_files =
      files ++
        [
          {udhcpd_conf_path, udhcpd_contents(ifname, dhcpd_config, tmpdir)}
        ]

    new_child_specs =
      child_specs ++
        [
          Supervisor.child_spec(
            {MuonTrap.Daemon,
             [
               "udhcpd",
               [
                 "-f",
                 udhcpd_conf_path
               ],
               Command.add_muon_options(
                 stderr_to_stdout: true,
                 log_output: :debug,
                 env: BEAMNotify.env(name: "vintage_net_comm", report_env: true)
               )
             ]},
            id: :udhcpd
          )
        ]

    %RawConfig{raw_config | files: new_files, child_specs: new_child_specs}
  end

  def add_config(raw_config, _config_without_dhcpd, _opts), do: raw_config

  defp udhcpd_contents(ifname, dhcpd, tmpdir) do
    pidfile = Path.join(tmpdir, "udhcpd.#{ifname}.pid")
    lease_file = Path.join(tmpdir, "udhcpd.#{ifname}.leases")

    initial = """
    interface #{ifname}
    pidfile #{pidfile}
    lease_file #{lease_file}
    notify_file #{BEAMNotify.bin_path()}
    """

    config = Enum.map(dhcpd, &to_udhcpd_string/1)
    IO.iodata_to_binary([initial, "\n", config, "\n"])
  end

  defp to_udhcpd_string({:start, val}) do
    "start #{IP.ip_to_string(val)}\n"
  end

  defp to_udhcpd_string({:end, val}) do
    "end #{IP.ip_to_string(val)}\n"
  end

  defp to_udhcpd_string({:max_leases, val}) do
    "max_leases #{val}\n"
  end

  defp to_udhcpd_string({:decline_time, val}) do
    "decline_time #{val}\n"
  end

  defp to_udhcpd_string({:conflict_time, val}) do
    "conflict_time #{val}\n"
  end

  defp to_udhcpd_string({:offer_time, val}) do
    "offer_time #{val}\n"
  end

  defp to_udhcpd_string({:min_lease, val}) do
    "min_lease #{val}\n"
  end

  defp to_udhcpd_string({:auto_time, val}) do
    "auto_time #{val}\n"
  end

  defp to_udhcpd_string({:static_leases, leases}) do
    Enum.map(leases, fn {mac, ip} ->
      "static_lease #{mac} #{IP.ip_to_string(ip)}\n"
    end)
  end

  defp to_udhcpd_string({:options, options}) do
    for option <- options do
      ["opt ", to_udhcpd_option_string(option), "\n"]
    end
  end

  defp to_udhcpd_option_string({option, ip}) when option in @ip_options do
    [to_string(option), " ", IP.ip_to_string(ip)]
  end

  defp to_udhcpd_option_string({option, ip_list}) when option in @ip_list_options do
    [to_string(option), " " | ip_list_to_iodata(ip_list)]
  end

  defp to_udhcpd_option_string({option, string_list}) when option in @string_list_options do
    [to_string(option), " " | Enum.intersperse(string_list, " ")]
  end

  defp to_udhcpd_option_string({option, value}) when option in @int_options do
    [to_string(option), " ", to_string(value)]
  end

  defp to_udhcpd_option_string({option, string}) when option in @string_options do
    [to_string(option), " ", string]
  end

  defp to_udhcpd_option_string({other_option, string}) when is_integer(other_option) do
    [to_string(other_option), " ", string]
  end

  defp ip_list_to_iodata(ip_list) do
    ip_list
    |> Enum.map(&IP.ip_to_string/1)
    |> Enum.intersperse(" ")
  end
end