lib/vintage_net/route/ip_route.ex

defmodule VintageNet.Route.IPRoute do
  @moduledoc """
  This module knows how to invoke the `ip` command to modify the Linux routing tables
  """

  require Logger

  alias VintageNet.{Command, IP, Route}

  @doc """
  Add a default route
  """
  @spec add_default_route(
          VintageNet.ifname(),
          :inet.ip_address(),
          Route.metric(),
          Route.table_index()
        ) :: :ok | {:error, any()}
  def add_default_route(ifname, route, metric, table_index) when is_integer(metric) do
    table_index_string = table_index_to_string(table_index)

    ip_cmd([
      "route",
      "add",
      "default",
      "table",
      table_index_string,
      "metric",
      to_string(metric),
      "via",
      IP.ip_to_string(route),
      "dev",
      ifname
    ])
  end

  @doc """
  Add a local route
  """
  @spec add_local_route(
          VintageNet.ifname(),
          :inet.ip_address(),
          VintageNet.prefix_length(),
          Route.metric(),
          Route.table_index()
        ) ::
          :ok | {:error, any()}
  def add_local_route(ifname, ip, subnet_bits, metric, table_index) when is_integer(metric) do
    subnet = IP.to_subnet(ip, subnet_bits)
    subnet_string = IP.cidr_to_string(subnet, subnet_bits)
    table_index_string = table_index_to_string(table_index)

    ip_cmd([
      "route",
      "add",
      subnet_string,
      "table",
      table_index_string,
      "metric",
      to_string(metric),
      "dev",
      ifname,
      "scope",
      "link",
      "src",
      IP.ip_to_string(ip)
    ])
  end

  @doc """
  Add a source IP address -> routing table rule
  """
  @spec add_rule(:inet.ip_address(), Route.table_index()) :: :ok | {:error, any()}
  def add_rule(ip_address, table_index) do
    table_index_string = table_index_to_string(table_index)

    ip_cmd(["rule", "add", "from", IP.ip_to_string(ip_address), "lookup", table_index_string])
  end

  @doc """
  Clear all routes on all interfaces
  """
  @spec clear_all_routes() :: :ok
  def clear_all_routes() do
    repeat_til_error(&clear_a_route/0)
  end

  @doc """
  Clear all rules that select the specified table or tables
  """
  @spec clear_all_rules(Route.table_index() | Enumerable.t()) :: :ok
  def clear_all_rules(table_index) when is_integer(table_index) or is_atom(table_index) do
    repeat_til_error(fn -> clear_a_rule(table_index) end)
  end

  def clear_all_rules(table_indices) do
    Enum.each(table_indices, &clear_all_rules/1)
  end

  defp repeat_til_error(function) do
    case function.() do
      :ok ->
        # Success. There could be more, though.
        repeat_til_error(function)

      _ ->
        # Error, so stop
        :ok
    end
  end

  @doc """
  Clear one default route out of the main table for any interface
  """
  @spec clear_a_route() :: :ok | {:error, any()}
  def clear_a_route() do
    ip_cmd(["route", "del", "default"])
  end

  @doc """
  Clear one default route that goes to the specified interface
  """
  @spec clear_a_route(VintageNet.ifname(), Route.table_index()) :: :ok | {:error, any()}
  def clear_a_route(ifname, table_index \\ :main) do
    table_index_string = table_index_to_string(table_index)
    ip_cmd(["route", "del", "default", "table", table_index_string, "dev", ifname])
  end

  @doc """
  Clear one local route
  """
  @spec clear_a_local_route(
          VintageNet.ifname(),
          :inet.ip_address(),
          VintageNet.prefix_length(),
          Route.metric(),
          Route.table_index()
        ) ::
          :ok | {:error, any()}
  def clear_a_local_route(ifname, ip, subnet_bits, metric, table_index) when is_integer(metric) do
    subnet = IP.to_subnet(ip, subnet_bits)
    subnet_string = IP.cidr_to_string(subnet, subnet_bits)
    table_index_string = table_index_to_string(table_index)

    ip_cmd([
      "route",
      "del",
      subnet_string,
      "table",
      table_index_string,
      "metric",
      to_string(metric),
      "dev",
      ifname,
      "scope",
      "link"
    ])
  end

  @doc """
  Clear one local route generically
  """
  @spec clear_a_local_route(VintageNet.ifname()) :: :ok | {:error, any()}
  def clear_a_local_route(ifname) do
    ip_cmd(["route", "del", "dev", ifname, "scope", "link"])
  end

  @doc """
  Clear out one rule
  """
  @spec clear_a_rule(Route.table_index()) :: :ok | {:error, any()}
  def clear_a_rule(table_index) do
    table_index_string = table_index_to_string(table_index)

    ip_cmd(["rule", "del", "lookup", table_index_string])
  end

  defp table_index_to_string(:main), do: "main"

  defp table_index_to_string(table_index) when table_index >= 0 and table_index <= 255,
    do: to_string(table_index)

  defp ip_cmd(args) do
    # Send iodata to the logger with the command and args interspersed with spaces
    # Logger.debug(Enum.intersperse(["ip" | args], " "))

    case Command.cmd("ip", args, stderr_to_stdout: true) do
      {_, 0} -> :ok
      {message, _error} -> {:error, message}
    end
  end
end