From 7c4d87dfb00f3fcc817f64583b8ee1d4cdd99fa0 Mon Sep 17 00:00:00 2001 From: chad Date: Mon, 10 Jan 2022 07:04:45 -0800 Subject: [PATCH] feat(ip_range): add support for iprange type --- lib/ecto_ip_range/ip_range.ex | 163 ++++++++++++++++++ lib/ecto_ip_range/postgrex.ex | 24 ++- .../postgrex/ip_range_extension.ex | 140 +++++++++++++++ lib/ecto_ip_range/util/cidr.ex | 9 + ...035738_add_test__schema_ip_range_table.exs | 9 + test/ecto_ip_range/ip_range_test.exs | 83 +++++++++ .../postgrex/ip_range_extension_test.exs | 97 +++++++++++ test/support/test_schema_ip_range.ex | 12 ++ 8 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 lib/ecto_ip_range/ip_range.ex create mode 100644 lib/ecto_ip_range/postgrex/ip_range_extension.ex create mode 100644 priv/test_repo/migrations/20220110035738_add_test__schema_ip_range_table.exs create mode 100644 test/ecto_ip_range/ip_range_test.exs create mode 100644 test/ecto_ip_range/postgrex/ip_range_extension_test.exs create mode 100644 test/support/test_schema_ip_range.ex diff --git a/lib/ecto_ip_range/ip_range.ex b/lib/ecto_ip_range/ip_range.ex new file mode 100644 index 0000000..cf853c0 --- /dev/null +++ b/lib/ecto_ip_range/ip_range.ex @@ -0,0 +1,163 @@ +defmodule EctoIPRange.IPRange do + @moduledoc """ + Struct for PostgreSQL `:iprange`. + + ## Usage + + When used during a changeset cast the following values are accepted: + + - `:inet.ip4_address()`: an IP4 tuple, e.g. `{127, 0, 0, 1}` (single address only) + - `binary` + - `"127.0.0.1"`: single address + - `"127.0.0.0/24"`: CIDR notation for a range from `127.0.0.0` to `127.0.0.255` + - `"127.0.0.1-127.0.0.2"`: arbitrary range + - `"2001:0db8:85a3:0000:0000:8a2e:0370:7334"`: single address + - `"2001:0db8:85a3:0000:0000:8a2e:0370:0000/112"`: CIDR notation for a range from + `2001:0db8:85a3:0000:0000:8a2e:0370:0000` to `2001:0db8:85a3:0000:0000:8a2e:0370:ffff` + - `"2001:0db8:85a3:0000:0000:8a2e:0370:7334-2001:0db8:85a3:0000:0000:8a2e:0370:7335"`: arbitrary range + - `EctoIPRange.IPRange.t()`: a pre-casted struct + + ## Fields + + * `range` + * `first_ip` + * `last_ip` + """ + + use Ecto.Type + + alias EctoIPRange.Util.{CIDR, Inet} + + @type t :: %__MODULE__{ + range: binary(), + first_ip: :inet.ip4_address() | :inet.ip6_address(), + last_ip: :inet.ip4_address() | :inet.ip6_address() + } + + defstruct [:range, :first_ip, :last_ip] + + @impl Ecto.Type + def type, do: :iprange + + @impl Ecto.Type + def cast({_, _, _, _} = ip4_address) do + case Inet.ntoa(ip4_address) do + address when is_binary(address) -> + {:ok, + %__MODULE__{ + range: address <> "/32", + first_ip: ip4_address, + last_ip: ip4_address + }} + + _ -> + :error + end + end + + def cast({_, _, _, _, _, _, _, _} = ip6_address) do + case Inet.ntoa(ip6_address) do + address when is_binary(address) -> + {:ok, + %__MODULE__{ + range: String.upcase(address) <> "/128", + first_ip: ip6_address, + last_ip: ip6_address + }} + + _ -> + :error + end + end + + def cast(address) when is_binary(address) do + cond do + String.contains?(address, "-") -> cast_range(address) + String.contains?(address, "/") -> cast_cidr(address) + true -> cast_binary(address) + end + end + + def cast(%__MODULE__{} = address), do: {:ok, address} + def cast(_), do: :error + + @impl Ecto.Type + def load(%__MODULE__{} = address), do: {:ok, address} + def load(_), do: :error + + @impl Ecto.Type + def dump(%__MODULE__{} = address), do: {:ok, address} + def dump(_), do: :error + + defp cast_binary(address) do + case Inet.parse_binary(address) do + {:ok, {_, _, _, _} = ip4_address} -> + {:ok, + %__MODULE__{ + range: address <> "/32", + first_ip: ip4_address, + last_ip: ip4_address + }} + + {:ok, {_, _, _, _, _, _, _, _} = ip6_address} -> + {:ok, + %__MODULE__{ + range: String.upcase(address) <> "/128", + first_ip: ip6_address, + last_ip: ip6_address + }} + + _ -> + :error + end + end + + defp cast_cidr(cidr) do + with [address, maskstring] <- String.split(cidr, "/", parts: 2), + {first_ip_address, last_ip_address} <- cast_ip4_or_ip6_cidr(address, maskstring) do + {:ok, + %__MODULE__{ + range: cidr, + first_ip: first_ip_address, + last_ip: last_ip_address + }} + end + end + + defp cast_range(range) do + with [first_ip, last_ip] <- String.split(range, "-", parts: 2), + {:ok, first_ip_address} <- Inet.parse_binary(first_ip), + {:ok, last_ip_address} <- Inet.parse_binary(last_ip) do + {:ok, + %__MODULE__{ + range: range, + first_ip: first_ip_address, + last_ip: last_ip_address + }} + else + _ -> :error + end + end + + defp cast_ip4_or_ip6_cidr(address, maskstring) do + case Inet.parse_binary(address) do + {:ok, {_, _, _, _}} -> cast_ip4_cidr(address, maskstring) + {:ok, {_, _, _, _, _, _, _, _}} -> cast_ip6_cidr(address, maskstring) + _ -> :error + end + end + + defp cast_ip4_cidr(address, maskstring) do + case Integer.parse(maskstring) do + {maskbits, ""} when maskbits in 0..32 -> CIDR.parse_ipv4(address, maskbits) + _ -> :error + end + end + + defp cast_ip6_cidr(address, maskstring) do + case Integer.parse(maskstring) do + {maskbits, ""} when maskbits in 0..128 -> CIDR.parse_ipv6(address, maskbits) + _ -> :error + end + end +end diff --git a/lib/ecto_ip_range/postgrex.ex b/lib/ecto_ip_range/postgrex.ex index 9cff8b1..8109dbd 100644 --- a/lib/ecto_ip_range/postgrex.ex +++ b/lib/ecto_ip_range/postgrex.ex @@ -3,16 +3,28 @@ defmodule EctoIPRange.Postgrex do Provides a simple list of available :postgrex extension modules. """ - alias EctoIPRange.Postgrex.IP4Extension - alias EctoIPRange.Postgrex.IP4RExtension - alias EctoIPRange.Postgrex.IP6Extension - alias EctoIPRange.Postgrex.IP6RExtension + alias EctoIPRange.Postgrex.{ + IP4Extension, + IP4RExtension, + IP6Extension, + IP6RExtension, + IPRangeExtension + } @doc """ Returns all available :postgrex extension modules. """ - @spec extensions() :: [IP4Extension | IP4RExtension | IP6Extension | IP6RExtension, ...] + @spec extensions() :: [ + IP4Extension | IP4RExtension | IP6Extension | IP6RExtension | IPRangeExtension, + ... + ] def extensions do - [IP4Extension, IP4RExtension, IP6Extension, IP6RExtension] + [ + IP4Extension, + IP4RExtension, + IP6Extension, + IP6RExtension, + IPRangeExtension + ] end end diff --git a/lib/ecto_ip_range/postgrex/ip_range_extension.ex b/lib/ecto_ip_range/postgrex/ip_range_extension.ex new file mode 100644 index 0000000..c68356f --- /dev/null +++ b/lib/ecto_ip_range/postgrex/ip_range_extension.ex @@ -0,0 +1,140 @@ +defmodule EctoIPRange.Postgrex.IPRangeExtension do + @moduledoc """ + Postgrex extension for "iprange" fields. + """ + + use Postgrex.BinaryExtension, type: "iprange" + + import Postgrex.BinaryUtils, warn: false + + alias EctoIPRange.{ + IPRange, + Util.CIDR, + Util.Inet + } + + def encode(_) do + quote location: :keep do + %IPRange{ + range: range, + first_ip: {first_a, first_b, first_c, first_d}, + last_ip: {last_a, last_b, last_c, last_d} + } -> + case CIDR.parse_mask(range) do + {:ok, mask} -> + <<12::int32, 2, mask, 0, 8, first_a, first_b, first_c, first_d, last_a, last_b, + last_c, last_d>> + + _ -> + <<12::int32, 2, 255, 0, 8, first_a, first_b, first_c, first_d, last_a, last_b, last_c, + last_d>> + end + + %IPRange{ + range: range, + first_ip: {first_a, first_b, first_c, first_d, first_e, first_f, first_g, first_h}, + last_ip: {last_a, last_b, last_c, last_d, last_e, last_f, last_g, last_h} + } -> + <<36::int32, 3, 255, 0, 32, first_a::size(16)-integer-unsigned, + first_b::size(16)-integer-unsigned, first_c::size(16)-integer-unsigned, + first_d::size(16)-integer-unsigned, first_e::size(16)-integer-unsigned, + first_f::size(16)-integer-unsigned, first_g::size(16)-integer-unsigned, + first_h::size(16)-integer-unsigned, last_a::size(16)-integer-unsigned, + last_b::size(16)-integer-unsigned, last_c::size(16)-integer-unsigned, + last_d::size(16)-integer-unsigned, last_e::size(16)-integer-unsigned, + last_f::size(16)-integer-unsigned, last_g::size(16)-integer-unsigned, + last_h::size(16)-integer-unsigned>> + end + end + + def decode(_) do + quote location: :keep do + <> -> + unquote(__MODULE__).decode_range(data) + end + end + + def decode_range(<<2, bits, _flag, 4, first_a, first_b, first_c, first_d>>) do + first_ip4_address = {first_a, first_b, first_c, first_d} + first_address_string = Inet.ntoa(first_ip4_address) + {cidr_first, cidr_last} = CIDR.parse_ipv4(first_address_string, bits) + + %IPRange{ + range: first_address_string <> "/" <> Integer.to_string(bits), + first_ip: cidr_first, + last_ip: cidr_last + } + end + + def decode_range( + <<2, _bits, _flag, 8, first_a, first_b, first_c, first_d, last_a, last_b, last_c, last_d>> + ) do + first_ip4_address = {first_a, first_b, first_c, first_d} + last_ip4_address = {last_a, last_b, last_c, last_d} + first_address_string = Inet.ntoa(first_ip4_address) + last_address_string = Inet.ntoa(last_ip4_address) + + %IPRange{ + range: first_address_string <> "-" <> last_address_string, + first_ip: first_ip4_address, + last_ip: last_ip4_address + } + end + + def decode_range( + <<3, bits, _flag, 8, first_a::size(16)-integer-unsigned, + first_b::size(16)-integer-unsigned, first_c::size(16)-integer-unsigned, + first_d::size(16)-integer-unsigned>> + ) do + first_address = {first_a, first_b, first_c, first_d, 0, 0, 0, 0} + first_address_string = Inet.ntoa(first_address) + {cidr_first, cidr_last} = CIDR.parse_ipv6(first_address_string, bits) + + %IPRange{ + range: first_address_string <> "/" <> Integer.to_string(bits), + first_ip: cidr_first, + last_ip: cidr_last + } + end + + def decode_range( + <<3, bits, _flag, 16, first_a::size(16)-integer-unsigned, + first_b::size(16)-integer-unsigned, first_c::size(16)-integer-unsigned, + first_d::size(16)-integer-unsigned, first_e::size(16)-integer-unsigned, + first_f::size(16)-integer-unsigned, first_g::size(16)-integer-unsigned, + first_h::size(16)-integer-unsigned>> + ) do + first_address = {first_a, first_b, first_c, first_d, first_e, first_f, first_g, first_h} + first_address_string = Inet.ntoa(first_address) + {cidr_first, cidr_last} = CIDR.parse_ipv6(first_address_string, bits) + + %IPRange{ + range: String.upcase(first_address_string) <> "/" <> Integer.to_string(bits), + first_ip: cidr_first, + last_ip: cidr_last + } + end + + def decode_range( + <<3, _bits, _flag, 32, first_a::size(16)-integer-unsigned, + first_b::size(16)-integer-unsigned, first_c::size(16)-integer-unsigned, + first_d::size(16)-integer-unsigned, first_e::size(16)-integer-unsigned, + first_f::size(16)-integer-unsigned, first_g::size(16)-integer-unsigned, + first_h::size(16)-integer-unsigned, last_a::size(16)-integer-unsigned, + last_b::size(16)-integer-unsigned, last_c::size(16)-integer-unsigned, + last_d::size(16)-integer-unsigned, last_e::size(16)-integer-unsigned, + last_f::size(16)-integer-unsigned, last_g::size(16)-integer-unsigned, + last_h::size(16)-integer-unsigned>> + ) do + first_address = {first_a, first_b, first_c, first_d, first_e, first_f, first_g, first_h} + last_address = {last_a, last_b, last_c, last_d, last_e, last_f, last_g, last_h} + first_address_string = Inet.ntoa(first_address) + last_address_string = Inet.ntoa(last_address) + + %IPRange{ + range: String.upcase(first_address_string) <> "-" <> String.upcase(last_address_string), + first_ip: first_address, + last_ip: last_address + } + end +end diff --git a/lib/ecto_ip_range/util/cidr.ex b/lib/ecto_ip_range/util/cidr.ex index c851cf2..2dcaa51 100644 --- a/lib/ecto_ip_range/util/cidr.ex +++ b/lib/ecto_ip_range/util/cidr.ex @@ -95,6 +95,15 @@ defmodule EctoIPRange.Util.CIDR do def parse_ipv6(_, _), do: :error + def parse_mask(address) do + with [_address, maskstring] <- String.split(address, "/", parts: 2), + {maskbits, ""} when maskbits in 0..32 <- Integer.parse(maskstring) do + {:ok, maskbits} + else + _ -> :error + end + end + defp ipv4_start_address({start_a, start_b, start_c, start_d}, maskbits) do {mask_a, mask_b, mask_c, mask_d} = cond do diff --git a/priv/test_repo/migrations/20220110035738_add_test__schema_ip_range_table.exs b/priv/test_repo/migrations/20220110035738_add_test__schema_ip_range_table.exs new file mode 100644 index 0000000..a4e0bbe --- /dev/null +++ b/priv/test_repo/migrations/20220110035738_add_test__schema_ip_range_table.exs @@ -0,0 +1,9 @@ +defmodule EctoIPRange.TestRepo.Migrations.AddTest_SchemaIpRangeTable do + use Ecto.Migration + + def change do + create table("test_schema_ip_range", primary_key: false) do + add :network, EctoIPRange.IPRange.type(), primary_key: true + end + end +end diff --git a/test/ecto_ip_range/ip_range_test.exs b/test/ecto_ip_range/ip_range_test.exs new file mode 100644 index 0000000..09df6b1 --- /dev/null +++ b/test/ecto_ip_range/ip_range_test.exs @@ -0,0 +1,83 @@ +defmodule EctoIPRange.IPRangeTest do + @moduledoc false + use ExUnit.Case, async: true + + alias EctoIPRange.IPRange + + test "cast cidr /32" do + address = "127.0.0.1/32" + casted = %IPRange{range: address, first_ip: {127, 0, 0, 1}, last_ip: {127, 0, 0, 1}} + + assert {:ok, ^casted} = IPRange.cast(address) + assert :error = IPRange.cast("a.b.c.d/32") + end + + test "cast cidr ipv6/128" do + address = "1:2:3:4:5:6:7:8/128" + + casted = %IPRange{ + range: address, + first_ip: {1, 2, 3, 4, 5, 6, 7, 8}, + last_ip: {1, 2, 3, 4, 5, 6, 7, 8} + } + + assert {:ok, ^casted} = IPRange.cast(address) + assert :error = IPRange.cast("s:t:u:v:w:x:y:z/128") + end + + test "error on invalid cidr maskbits" do + assert :error = IPRange.cast("1:2:3:4:5:6:7:8/256") + assert :error = IPRange.cast("1.2.3.4/x") + assert :error = IPRange.cast("1:2:3:4:5:6:7:8/XX") + end + + test "cast ip4_address" do + ip4_address = {127, 0, 0, 1} + casted = %IPRange{range: "127.0.0.1/32", first_ip: ip4_address, last_ip: ip4_address} + + assert {:ok, ^casted} = IPRange.cast(ip4_address) + assert {:ok, ^casted} = IPRange.cast("127.0.0.1") + + assert :error = IPRange.cast({"a", "b", "c", "d"}) + assert :error = IPRange.cast("a.b.c.d") + end + + test "cast ip6_address" do + ip6_address = {7871, 56_130, 38_644, 19_455, 38_291, 16_847, 30_069, 45_108} + + casted = %IPRange{ + range: "1EBF:DB42:96F4:4BFF:9593:41CF:7575:B034/128", + first_ip: ip6_address, + last_ip: ip6_address + } + + assert {:ok, ^casted} = IPRange.cast(ip6_address) + assert {:ok, ^casted} = IPRange.cast("1EBF:DB42:96F4:4BFF:9593:41CF:7575:B034") + + assert :error = IPRange.cast({"s", "t", "u", "v", "w", "x", "y", "z"}) + assert :error = IPRange.cast("s:t:u:v:w:x:y:z") + end + + test "cast ip4 range" do + range = "1.1.1.1-2.2.2.2" + casted = %IPRange{range: range, first_ip: {1, 1, 1, 1}, last_ip: {2, 2, 2, 2}} + + assert {:ok, ^casted} = IPRange.cast(range) + assert :error = IPRange.cast("1.1.1.1-a.b.c.d") + end + + test "cast struct" do + assert {:ok, %IPRange{}} = IPRange.cast(%IPRange{}) + assert :error = IPRange.cast("invalid") + end + + test "dump" do + assert {:ok, %IPRange{}} = IPRange.dump(%IPRange{}) + assert :error = IPRange.dump("invalid") + end + + test "load" do + assert {:ok, %IPRange{}} = IPRange.load(%IPRange{}) + assert :error = IPRange.load("invalid") + end +end diff --git a/test/ecto_ip_range/postgrex/ip_range_extension_test.exs b/test/ecto_ip_range/postgrex/ip_range_extension_test.exs new file mode 100644 index 0000000..ca9f4ef --- /dev/null +++ b/test/ecto_ip_range/postgrex/ip_range_extension_test.exs @@ -0,0 +1,97 @@ +defmodule EctoIPRange.Postgrex.IPRangeExtensionTest do + @moduledoc false + + use EctoIPRange.RepoCase, async: true + + alias EctoIPRange.TestSchemaIPRange + + test "single ip insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1.2.3.4"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "cidr /32 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1.2.3.4/32"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "cidr /20 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "192.168.0.0/20"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "custom ip range insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1.2.3.4-2.3.4.5"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "single ip6 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1EBF:DB42:96F4:4BFF:9593:41CF:7575:B034"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "single /8 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1::/24"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "cidr /128 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1:2:3:4:5:6:7:8/128"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "cidr /72 insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1:2:3:4::/72"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end + + test "custom ip6 range insert/select" do + value = + %TestSchemaIPRange{} + |> Ecto.Changeset.cast(%{network: "1:2:3:4:5:6:7:8-2:3:4:5:6:7:8:9"}, [:network]) + |> Ecto.Changeset.validate_required([:network]) + |> TestRepo.insert!() + + assert [^value] = TestRepo.all(TestSchemaIPRange) + end +end diff --git a/test/support/test_schema_ip_range.ex b/test/support/test_schema_ip_range.ex new file mode 100644 index 0000000..3f94e8a --- /dev/null +++ b/test/support/test_schema_ip_range.ex @@ -0,0 +1,12 @@ +defmodule EctoIPRange.TestSchemaIPRange do + @moduledoc false + + use Ecto.Schema + + alias EctoIPRange.IPRange + + @primary_key {:network, IPRange, []} + + schema "test_schema_ip_range" do + end +end