lib/one_dhcpd/server.ex

defmodule OneDHCPD.Server do
  use GenServer
  alias OneDHCPD.{Message, ARP, IPCalculator}

  require Logger

  @moduledoc """
  This is the OneDHCPD Server.

  Add it to a supervision tree in your application to use.
  """

  @dhcp_server_port 67
  @dhcp_client_port 68
  @ip_broadcast {255, 255, 255, 255}

  defmodule State do
    @moduledoc false
    defstruct [
      :ifname,
      :socket,
      :subnet,
      :subnet_mask,
      :our_ip_address,
      :their_ip_address
    ]

    @type t :: %__MODULE__{
            ifname: String.t(),
            socket: any(),
            subnet: :inet.ip4_address(),
            subnet_mask: :inet.ip4_address(),
            our_ip_address: :inet.ip4_address(),
            their_ip_address: :inet.ip4_address()
          }
  end

  @doc """
  Return the server's name for the specified interface
  """
  @spec server_name(String.t()) :: atom()
  def server_name(ifname) do
    Module.concat(__MODULE__, String.to_atom(ifname))
  end

  def child_spec([ifname, opts]) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [ifname, opts]},
      type: :worker,
      restart: :permanent,
      shutdown: 500
    }
  end

  @doc """
  Start a DHCP Server that works for one client.

  Options:

  * `port`: the port for the server (only specify if testing)
  * `subnet`: a /30 subnet to allocate addresses (default is {192, 168, 200, 0})
  """
  @spec start_link(String.t(), keyword()) :: GenServer.on_start()
  def start_link(ifname, options) do
    GenServer.start_link(__MODULE__, [{:ifname, ifname} | options], name: server_name(ifname))
  end

  def stop(ifname) do
    GenServer.stop(server_name(ifname))
  end

  @spec init(keyword()) :: {:ok, OneDHCPD.Server.State.t()} | {:stop, atom()}
  def init(options) do
    ifname = Keyword.get(options, :ifname)
    port = Keyword.get(options, :port, @dhcp_server_port)
    subnet = Keyword.get(options, :subnet) || IPCalculator.default_subnet(ifname)
    subnet_mask = IPCalculator.mask()
    our_ip_address = IPCalculator.our_ip_address(subnet)
    their_ip_address = IPCalculator.their_ip_address(subnet)

    socket_opts = [
      :binary,
      {:bind_to_device, ifname},
      {:broadcast, true},
      {:active, true}
    ]

    case :gen_udp.open(port, socket_opts) do
      {:ok, socket} ->
        {:ok,
         %State{
           socket: socket,
           ifname: ifname,
           subnet: subnet,
           subnet_mask: subnet_mask,
           our_ip_address: our_ip_address,
           their_ip_address: their_ip_address
         }}

      {:error, :einval} ->
        Logger.error("OneDHCPD can't open port #{port} on #{ifname}. Check permissions")
        {:stop, :check_port_and_ifname}

      {:error, other} ->
        {:stop, other}
    end
  end

  def handle_info({:udp, socket, _ip, @dhcp_client_port, packet}, %State{socket: socket} = state) do
    case Message.decode(packet) do
      {:error, _reason} ->
        # Bad packet?
        {:noreply, state}

      message ->
        message_type = Keyword.get(message.options, :dhcp_message_type)

        handle_dhcp(message_type, message, state)
    end
  end

  def handle_info(data, state) do
    Logger.error("dhcpd: not sure what to do with #{inspect(data)}")
    {:noreply, state}
  end

  defp handle_dhcp(:discover, message, state) do
    Logger.debug("Responding to DHCP discover on #{state.ifname}")

    # Handle a DHCP Discover message
    response =
      Message.response(message)
      |> Map.put(:yiaddr, state.their_ip_address)
      |> Map.put(:siaddr, state.our_ip_address)
      |> Map.put(:options,
        dhcp_message_type: :offer,
        subnet_mask: state.subnet_mask,
        dhcp_server_identifier: state.our_ip_address,
        dhcp_lease_time: 86400
      )

    dhcp_packet = Message.encode(response)

    :ok = :gen_udp.send(state.socket, @ip_broadcast, @dhcp_client_port, dhcp_packet)
    {:noreply, state}
  end

  defp handle_dhcp(:request, message, state) do
    Logger.debug("Responding to DHCP request on #{state.ifname}")

    if message.options[:dhcp_requested_address] == state.their_ip_address do
      # Send an DHCP ack
      response =
        Message.response(message)
        |> Map.put(:yiaddr, state.their_ip_address)
        |> Map.put(:siaddr, state.our_ip_address)
        |> Map.put(:options,
          dhcp_message_type: :ack,
          subnet_mask: state.subnet_mask,
          dhcp_server_identifier: state.our_ip_address,
          dhcp_lease_time: message.options[:dhcp_lease_time] || 86400
        )

      # Update our ARP cache so that we can send a response
      # directly to the client device.
      :ok = ARP.replace(state.ifname, state.their_ip_address, message.chaddr)

      dhcp_packet = Message.encode(response)

      :ok = :gen_udp.send(state.socket, state.their_ip_address, @dhcp_client_port, dhcp_packet)
    else
      # Send a DHCP nak
      response =
        Message.response(message)
        |> Map.put(:broadcast_flag, 1)
        |> Map.put(:siaddr, state.our_ip_address)
        |> Map.put(:options,
          dhcp_message_type: :nak,
          dhcp_server_identifier: state.our_ip_address,
          dhcp_message: "requested address not available"
        )

      dhcp_packet = Message.encode(response)

      :ok = :gen_udp.send(state.socket, @ip_broadcast, @dhcp_client_port, dhcp_packet)
    end

    {:noreply, state}
  end

  defp handle_dhcp(_message_type, message, state) do
    Logger.info("Ignoring DHCP message: #{inspect(message)}")
    {:noreply, state}
  end

  def terminate(_, state) do
    :gen_udp.close(state.socket)
  end
end