Fusic Tech Blog

Fusicエンジニアによる技術ブログ

ソフトウェアルータにElixirでFirewall機能を追加
2024/03/25

ソフトウェアルータにElixirでFirewall機能を追加

どうもこんにちは。小原です。前回開発合宿で作っていたソフトウェアルータの基本部分のことを書きましたが、今回はそのソフトウェアルータの拡張部分を書いて行きたいと思います。

実装したもの

主に二つのものの実装を行いました。

  • Firewall
  • 障害ツール

Firewallはお馴染みのFirewallの機能で、送信元、送信先のIPとポートから通信を遮断するものです。 障害ツールソフトウェアルータで意図的に障害を起こすものになります。Netflixのカオスモンキーっというツールが意図的に障害を起こすツールでそれを真似てネットワークでの障害ツールを作りたいと思い作りました。 ソフトウェアルータの実装の部分はErlangで実装して拡張部分はElixirで書いています。 そしてソフトウェアルータの部分でパイプライン処理でルーティングの間に別の処理を挟み込めるようにしています。これを利用してFirewallの実装をすすめていきました。

Firewallの実装

ではFirewallの実装を見て行きたいと思います。 まずはFirewallの宣言部分を見て行きたいと思います。

  firewall :default do
    allow(
      source_ip: {192, 168, 20, 0},
      source_netmask: {255, 255, 255, 0},
      protocol: :ip
    )
    allow(
      source_ip: {192, 168, 40, 0},
      source_netmask: {255, 255, 255, 0},
      protocol: :tcp
    )

    deny()
  end

宣言自体はマクロで実装しています。 :default はFirewallのIDのようなものでFirewallを別のものを利用したい時に変更できるようにしました(これをどう使うかはわからないですが)。 allowマクロで送信元、宛先のIPとポートを宣言します。ここでは送信元のIPのネットワーク帯を宣言しています。 protocolでプロトコルを決めています。ipでは今の所IP部分のみチェクしています。 tcpudpにするとポートまで見るようにしています。 最後のdeny()を宣言することで全てのパケットを遮断するようにしています。 denyマクロでもallow同様に送信元、宛先のIPとポートを宣言できます。 そして宣言した順番でパケットの評価をして行き、最初にマッチしたものの処理を実行します。最初にallowにマッチしたらパケットの通過を許可。denyにマッチした場合はパケットを遮断するようにしています。 マクロ自体の実装はallowdenyもほぼほぼ一緒なのでallowの実装のみ見て行きましょう。

  """
  defmacro allow(c) do
    quote do
      Eshe.Firewall.__allow__(__MODULE__, Map.new(unquote(c)))
    end
  end

  def __allow__(module, c) do
    record = Map.merge(@default_firewall_record, c)
    Module.put_attribute(module, :change_firewall_record, {:allow_record, record})
  end

defmacro allow(c)が呼ばれ、すぐに__allow__関数に処理を渡します。引数のFirewallの条件はキーリストなんであとで使いやすいようにしたいのでマップに変換してます。 @default_firewall_recordと先ほどの条件をマージして定義してない条件をデフォルトの条件を追加しておきます。 Module.put_attribute(module, :change_firewall_record, {:allow_record, record})で許可するときの処理を:change_firewall_recordに追加します。 change_firewall_recordにはFirewallで宣言した条件を順番に追加して行きます。allowでの宣言なんでallow_recordとして追加します。 denyの場合はdeny_recordとして追加するようにしています。

ちなみにdeny()の実装は以下のようにしています。

  defmacro deny() do
    quote do
      Eshe.Firewall.__deny__(__MODULE__, %{
        source_ip: {0, 0, 0, 0},
        source_netmask: {0, 0, 0, 0},
        dest_ip: {0, 0, 0, 0},
        dest_netmask: {0, 0, 0, 0}
      })
    end
  end

ネットマスクを0.0.0.0にしているので全てのパケットにマッチするようにしています。 これで全てのパケットを遮断します。

パケットの遮断

次に実際のパケットの遮断処理を見て行きたいと思います。 まずは核のソフトウェアルータにパイプライン処理を挟み込むところから。

  defmacro firewall_through(identifier) do
    quote do
      identifier = unquote(identifier)
      :brook_pipeline.save_before_ip_pipeline(Eshe.Firewall.firewall_filter(identifier))
    end
  end
  def firewall_filter(identifier) do
    filter = fetch_filter(Eshe.Supervisor.route_firewall(), identifier)

    fn data, option ->
      case is_allow_filter(filter, data) do
        :ok ->
          {:ok, data, option}

        error ->
          {:error, error}
      end
    end
  end
  
    defp fetch_filter(route_firewall, identifier) do
    record = for %{identifier: id, record: record} <- route_firewall, identifier == id, do: record

    record
    |> List.flatten()
  end

firewall_throughマクロを呼び引数にFirewwawllのIDを渡します。ここでは:defaultを渡しておきましょう。 :brook_pipeline.save_before_ip_pipeline(Eshe.Firewall.firewall_filter(identifier))でパイプライン処理を渡します。FirewallはIPパケットを評価する前に行うようにしました。 firewall_filter関数の返り値で関数を渡します。この返り値の関数内で先ほどのchange_firewall_recordに追加していった条件等を使えるようにします。fetch_filterの中でFirewallのIDを元にFirewallの条件を取り出します。

条件式

最後にパケットが条件にマッチしているかどうかの確認を行なって行きます。

  def is_allow_filter([head | tail], data) do
    case record_filter(head, data) do
      :ok ->
        :ok

      :error ->
        {:error, :bad_match}

      :next ->
        is_allow_filter(tail, data)
    end
  end

is_allow_filter関数で先ほど追加したFirewallの条件を順番にチェックして行きます。

  defp record_filter({:deny_record, record}, data) do
    if match(record, data) do
      :error
    else
      :next
    end
  end

  defp record_filter({:allow_record, record}, data) do
    if match(record, data) do
      :ok
    else
      :next
    end
  end

denyallowそれぞれの条件でマッチした場合の処理を実装します。 denyの条件にマッチした場合はerrorを、allowの条件にマッチしたらokを返します。 マッチしなかった場合はnextを返し、次の条件を評価します。

  def match(
        %{protocol: protocol} = record,
        <<version::size(4), len::size(4), _head::size(88), source_ip::size(32), dest_ip::size(32),
          other::binary>>
      )
      when protocol in [:tcp, :udp] do
    {source_port, dest_port} = fetch_port(len, other)

    with res <- match_ip([], record[:dest_ip], record[:dest_netmask], dest_ip),
         res <- match_ip(res, record[:source_ip], record[:source_netmask], source_ip),
         res <- match_port(res, record[:source_port], source_port),
         res <- match_port(res, record[:dest_port], dest_port),
         res <- Enum.filter(res, &amp;(&amp;1 != nil)),
         {:ok, _value} <- Enum.fetch(res, 0) do
      Enum.all?(res, fn r -> r == true end)
    else
      _ ->
        false
    end
  end

ここで条件にマッチしているかどうかをチェックします。 protocoltcpudpかどうかチェックをし、tcpudpの場合はポートまで確認します。 第二引数にパケットのデータを渡します。 withの中で送信元、送信先のIPとポート番号をチェックしていきます。 その結果をresに保存して行き、Enum.all?(res, fn r -> r == true end)で全てにマッチしたかどうかを確認します。全てにマッチした場合のみtrueを返しそれ以外はfalseを返すので次の条件に進んで行きます。

まとめ

これで一通りFirewallの実装処理が完了しました。送信元と送信先のIPとポート番号パケットを遮断できたのでFirewallとしての機能ははたしているかと。 Firewallの宣言部分の実装をどうするかで迷っていたのですが、EctoのSchemaとかPhoenixのルーティング処理とかのソースを読んで参考にさせてもらいました。 このFirewallの実装を応用して障害ツールの実装したのでまた別の機会に実装した内容と実装方法は書いて行きたいと思います。

kobaru

kobaru

インフラ好きっ子