diff --git a/.github/workflows/iana-registry-check.yml b/.github/workflows/iana-registry-check.yml new file mode 100644 index 000000000000..947e642a9dd2 --- /dev/null +++ b/.github/workflows/iana-registry-check.yml @@ -0,0 +1,57 @@ +name: IANA registry check + +on: + schedule: + # Runs at 09:00 UTC on the 1st of every month. + - cron: "0 9 1 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-iana-registries: + name: Check IANA IP registries up to date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: marocchino/tool-versions-action@18a164fa2b0db1cc1edf7305fcb17ace36d1c306 # v1.2.0 + id: versions + + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - run: mix deps.get + + - run: mix download_ip_registries + + - name: Fail if the downloaded registries differ from what's committed + run: | + # IANA serves these CSVs with CRLF line endings while the committed files use LF; + # --ignore-space-at-eol treats that difference as a non-change so only real content changes fail the check. + if ! git diff --exit-code --quiet --ignore-space-at-eol -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv; then + echo "IANA special-purpose registries are out of date. Run 'mix download_ip_registries' and commit the changes." + git diff --ignore-space-at-eol -- priv/ip/iana-ipv4-special-registry.csv priv/ip/iana-ipv6-special-registry.csv + exit 1 + fi + + - name: Notify team on success + if: ${{ success() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

IANA IP registry is up to date.

"}' + + - name: Notify team on failure + if: ${{ failure() }} + uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2.0.0 + with: + url: ${{ secrets.BUILD_NOTIFICATION_URL }} + method: 'POST' + customHeaders: '{"Content-Type": "application/json"}' + data: '{"content": "

⚠️ IANA IP registry check failed.

"}' diff --git a/lib/ip/tools.ex b/lib/ip/tools.ex new file mode 100644 index 000000000000..e5a4e8525436 --- /dev/null +++ b/lib/ip/tools.ex @@ -0,0 +1,56 @@ +defmodule Plausible.IP.Tools do + @moduledoc """ + Recognizes reserved and private IPv4/IPv6 addresses. + See: `Plausible.IP.Tools.Registry` + """ + + @external_resource Plausible.IP.Tools.Registry.ipv4_registry_path() + @external_resource Plausible.IP.Tools.Registry.ipv6_registry_path() + + @clauses Plausible.IP.Tools.Registry.entries() + + @doc """ + Returns the ranges used in `reserved?/1`, for testing purposes. + """ + @spec ranges() :: [%{cidr: String.t(), name: String.t(), reserved: boolean()}] + def ranges do + Enum.map(@clauses, &Map.take(&1, [:cidr, :name, :reserved])) + end + + @doc """ + Determines whether IP falls within a reserved or private address range. + Accepts already parsed `:inet` address tuples. + + IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) are unwrapped and delegate to + the IPv4 rules for the embedded address, rather than being uniformly + treated as reserved. + """ + @spec reserved?(:inet.ip_address()) :: boolean() + for %{pattern: pattern, guard: guard, reserved: reserved} <- @clauses do + def reserved?(unquote(pattern)) when unquote(guard), do: unquote(reserved) + end + + def reserved?({0, 0, 0, 0, 0, 0xFFFF, hi, lo}) do + reserved?({div(hi, 256), rem(hi, 256), div(lo, 256), rem(lo, 256)}) + end + + def reserved?(ip) when is_tuple(ip) and tuple_size(ip) == 4, do: false + def reserved?(ip) when is_tuple(ip) and tuple_size(ip) == 8, do: false + + @doc """ + Determines if IP is allowed, i.e. valid and not reserved/private. + """ + @spec allowed?(:inet.ip_address() | String.t()) :: boolean() + def allowed?(ip) when is_binary(ip) do + case :inet.parse_address(String.to_charlist(ip)) do + {:ok, address} -> allowed?(address) + {:error, _reason} -> false + end + end + + def allowed?(ip) when is_tuple(ip) and tuple_size(ip) in [4, 8] do + not reserved?(ip) + end + + def allowed?(_ip), do: false +end diff --git a/lib/ip/tools/registry.ex b/lib/ip/tools/registry.ex new file mode 100644 index 000000000000..99c84aef52a8 --- /dev/null +++ b/lib/ip/tools/registry.ex @@ -0,0 +1,149 @@ +defmodule Plausible.IP.Tools.Registry do + @moduledoc """ + Compile-time-only helpers for `Plausible.IP.Tools`. + + Parses IANA RFC4180 CSV plus supplemental multicast ranges + into a list of function clause meta data. + """ + + import Bitwise + + def ipv4_registry_path, + do: Application.app_dir(:plausible, "priv/ip/iana-ipv4-special-registry.csv") + + def ipv6_registry_path, + do: Application.app_dir(:plausible, "priv/ip/iana-ipv6-special-registry.csv") + + def entries() do + ipv4 = load(ipv4_registry_path()) + + ipv6 = + ipv6_registry_path() + |> load() + # IPv4-mapped IPv6 is excluded here: it isn't a range + # that's uniformly reserved or public, it's a container for an entire + # embedded IPv4 address. Plausible.IP.Tools handles it by unwrapping + # the embedded address and delegating back to the IPv4 clauses instead. + |> Enum.reject(&(&1.cidr == "::ffff:0:0/96")) + + (ipv4 ++ ipv6 ++ multicast()) + |> Enum.sort_by(& &1.prefix_len, :desc) + |> Enum.map(&to_clause/1) + end + + defp multicast do + [ + entry_from_cidr("224.0.0.0/4", "Multicast", true), + entry_from_cidr("ff00::/8", "Multicast", true) + ] + end + + defp rows_to_entries(rows), do: Enum.flat_map(rows, &row_to_entry/1) + + # Address Block,Name,RFC,Allocation Date,Termination Date,Source, + # Destination,Forwardable,Globally Reachable,Reserved-by-Protocol + defp row_to_entry(row) do + address_block = Enum.at(row, 0) + name = Enum.at(row, 1) + termination_date = row |> Enum.at(4) |> strip_footnotes() + globally_reachable = row |> Enum.at(8) |> strip_footnotes() + + reserved? = termination_date == "N/A" and globally_reachable == "False" + + address_block + |> strip_footnotes() + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&entry_from_cidr(&1, name, reserved?)) + end + + defp strip_footnotes(nil), do: nil + + defp strip_footnotes(field) do + field + |> String.replace(~r/\s*\[\d+\]/, "") + |> String.trim() + end + + defp entry_from_cidr(cidr, name, reserved?) do + [addr_str, prefix_str] = String.split(cidr, "/") + {:ok, address} = :inet.parse_address(String.to_charlist(addr_str)) + + %{ + cidr: cidr, + name: name, + words: Tuple.to_list(address), + prefix_len: String.to_integer(prefix_str), + reserved: reserved? + } + end + + defp to_clause(%{ + words: words, + prefix_len: prefix_len, + reserved: reserved, + cidr: cidr, + name: name + }) do + word_bits = if length(words) == 4, do: 8, else: 16 + indexed_words = Enum.with_index(words) + + pattern_words = Enum.map(indexed_words, &pattern_word(&1, word_bits, prefix_len)) + + guard = + indexed_words + |> Enum.map(&guard_for_word(&1, word_bits, prefix_len)) + |> Enum.reject(&is_nil/1) + |> combine_guards() + + %{pattern: {:{}, [], pattern_words}, guard: guard, reserved: reserved, cidr: cidr, name: name} + end + + defp pattern_word({word, index}, word_bits, prefix_len) do + bits_before = index * word_bits + bits_after = bits_before + word_bits + + cond do + bits_after <= prefix_len -> word + bits_before >= prefix_len -> Macro.var(:"_w#{index}", nil) + true -> Macro.var(:"w#{index}", nil) + end + end + + defp guard_for_word({word, index}, word_bits, prefix_len) do + bits_before = index * word_bits + bits_after = bits_before + word_bits + + if bits_before < prefix_len and bits_after > prefix_len do + var = Macro.var(:"w#{index}", nil) + bits_in_word = prefix_len - bits_before + free_bits = word_bits - bits_in_word + full_mask = (1 <<< word_bits) - 1 + mask = full_mask - ((1 <<< free_bits) - 1) + masked_value = word &&& mask + + quote do + band(unquote(var), unquote(mask)) === unquote(masked_value) + end + end + end + + defp combine_guards([]), do: true + defp combine_guards([guard]), do: guard + + defp combine_guards([guard | rest]) do + Enum.reduce(rest, guard, fn g, acc -> + quote do + unquote(acc) and unquote(g) + end + end) + end + + defp load(path) do + path + |> File.read!() + |> NimbleCSV.RFC4180.parse_string() + |> rows_to_entries() + end +end diff --git a/lib/mix/tasks/download_ip_registries.ex b/lib/mix/tasks/download_ip_registries.ex new file mode 100644 index 000000000000..96cd7bd98125 --- /dev/null +++ b/lib/mix/tasks/download_ip_registries.ex @@ -0,0 +1,39 @@ +defmodule Mix.Tasks.DownloadIpRegistries do + @moduledoc """ + Refreshes the IANA special-purpose address registries used by + `Plausible.IP.Tools` at compile time to recognize reserved/private IPs. + """ + + use Mix.Task + require Logger + + alias Plausible.IP + + # coveralls-ignore-start + + @sources %{ + IP.Tools.Registry.ipv4_registry_path() => + "https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry-1.csv", + IP.Tools.Registry.ipv6_registry_path() => + "https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry-1.csv" + } + + def run(_) do + Application.ensure_all_started(:req) + + for {path, url} <- @sources do + path |> Path.dirname() |> File.mkdir_p!() + + Logger.notice("Downloading #{url}") + + case Req.get(url) do + {:ok, %{status: 200, body: body}} -> + File.write!(path, body) + Logger.notice("Saved #{path}") + + other -> + Logger.error("Unable to download #{url}. Response: #{inspect(other)}") + end + end + end +end diff --git a/mix.exs b/mix.exs index 052f80b29441..63aa67da359e 100644 --- a/mix.exs +++ b/mix.exs @@ -103,6 +103,7 @@ defmodule Plausible.MixProject do {:location, git: "https://github.com/plausible/location.git"}, {:mox, "~> 1.0", only: [:test, :ce_test, :e2e_test]}, {:nanoid, "~> 2.1.0"}, + {:nimble_csv, "~> 1.3"}, {:nimble_totp, "~> 1.0"}, {:oban, "~> 2.20.1"}, {:observer_cli, "~> 1.7"}, diff --git a/priv/ip/iana-ipv4-special-registry.csv b/priv/ip/iana-ipv4-special-registry.csv new file mode 100644 index 000000000000..c5996a45a72c --- /dev/null +++ b/priv/ip/iana-ipv4-special-registry.csv @@ -0,0 +1,27 @@ +Address Block,Name,RFC,Allocation Date,Termination Date,Source,Destination,Forwardable,Globally Reachable,Reserved-by-Protocol +0.0.0.0/8,"""This network""","[RFC791], Section 3.2",1981-09,N/A,True,False,False,False,True +0.0.0.0/32,"""This host on this network""","[RFC1122], Section 3.2.1.3",1981-09,N/A,True,False,False,False,True +10.0.0.0/8,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +100.64.0.0/10,Shared Address Space,[RFC6598],2012-04,N/A,True,True,True,False,False +127.0.0.0/8,Loopback,"[RFC1122], Section 3.2.1.3",1981-09,N/A,False [1],False [1],False [1],False [1],True +169.254.0.0/16,Link Local,[RFC3927],2005-05,N/A,True,True,False,False,True +172.16.0.0/12,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +192.0.0.0/24 [2],IETF Protocol Assignments,"[RFC6890], Section 2.1",2010-01,N/A,False,False,False,False,False +192.0.0.0/29,IPv4 Service Continuity Prefix,[RFC7335],2011-06,N/A,True,True,True,False,False +192.0.0.8/32,IPv4 dummy address,[RFC7600],2015-03,N/A,True,False,False,False,False +192.0.0.9/32,Port Control Protocol Anycast,[RFC7723],2015-10,N/A,True,True,True,True,False +192.0.0.10/32,Traversal Using Relays around NAT Anycast,[RFC8155],2017-02,N/A,True,True,True,True,False +"192.0.0.170/32, 192.0.0.171/32",NAT64/DNS64 Discovery,"[RFC8880][RFC7050], Section 2.2",2013-02,N/A,False,False,False,False,True +192.0.2.0/24,Documentation (TEST-NET-1),[RFC5737],2010-01,N/A,False,False,False,False,False +192.31.196.0/24,AS112-v4,[RFC7535],2014-12,N/A,True,True,True,True,False +192.52.193.0/24,AMT,[RFC7450],2014-12,N/A,True,True,True,True,False +192.88.99.0/24,Deprecated (6to4 Relay Anycast),[RFC7526],2001-06,2015-03,,,,, +192.88.99.2/32,6a44-relay anycast address,[RFC6751],2012-10,N/A,True,True,True,False,False +192.168.0.0/16,Private-Use,[RFC1918],1996-02,N/A,True,True,True,False,False +192.175.48.0/24,Direct Delegation AS112 Service,[RFC7534],1996-01,N/A,True,True,True,True,False +198.18.0.0/15,Benchmarking,[RFC2544],1999-03,N/A,True,True,True,False,False +198.51.100.0/24,Documentation (TEST-NET-2),[RFC5737],2010-01,N/A,False,False,False,False,False +203.0.113.0/24,Documentation (TEST-NET-3),[RFC5737],2010-01,N/A,False,False,False,False,False +240.0.0.0/4,Reserved,"[RFC1112], Section 4",1989-08,N/A,False,False,False,False,True +255.255.255.255/32,Limited Broadcast,"[RFC8190] + [RFC919], Section 7",1984-10,N/A,False,True,False,False,True diff --git a/priv/ip/iana-ipv6-special-registry.csv b/priv/ip/iana-ipv6-special-registry.csv new file mode 100644 index 000000000000..93ead430cef6 --- /dev/null +++ b/priv/ip/iana-ipv6-special-registry.csv @@ -0,0 +1,28 @@ +Address Block,Name,RFC,Allocation Date,Termination Date,Source,Destination,Forwardable,Globally Reachable,Reserved-by-Protocol +::1/128,Loopback Address,[RFC4291],2006-02,N/A,False,False,False,False,True +::/128,Unspecified Address,[RFC4291],2006-02,N/A,True,False,False,False,True +::ffff:0:0/96,IPv4-mapped Address,[RFC4291],2006-02,N/A,False,False,False,False,True +64:ff9b::/96,IPv4-IPv6 Translat.,[RFC6052],2010-10,N/A,True,True,True,True,False +64:ff9b:1::/48,IPv4-IPv6 Translat.,[RFC8215],2017-06,N/A,True,True,True,False,False +100::/64,Discard-Only Address Block,[RFC6666],2012-06,N/A,True,True,True,False,False +100:0:0:1::/64,Dummy IPv6 Prefix,[RFC9780],2025-04,N/A,True,False,False,False,False +2001::/23,IETF Protocol Assignments,[RFC2928],2000-09,N/A,False [1],False [1],False [1],False [1],False +2001::/32,TEREDO,"[RFC4380] + [RFC8190]",2006-01,N/A,True,True,True,N/A [2],False +2001:1::1/128,Port Control Protocol Anycast,[RFC7723],2015-10,N/A,True,True,True,True,False +2001:1::2/128,Traversal Using Relays around NAT Anycast,[RFC8155],2017-02,N/A,True,True,True,True,False +2001:1::3/128,DNS-SD Service Registration Protocol Anycast,[RFC9665],2024-04,N/A,True,True,True,True,False +2001:2::/48,Benchmarking,[RFC5180][RFC Errata 1752],2008-04,N/A,True,True,True,False,False +2001:3::/32,AMT,[RFC7450],2014-12,N/A,True,True,True,True,False +2001:4:112::/48,AS112-v6,[RFC7535],2014-12,N/A,True,True,True,True,False +2001:10::/28,Deprecated (previously ORCHID),[RFC4843],2007-03,2014-03,,,,, +2001:20::/28,ORCHIDv2,[RFC7343],2014-07,N/A,True,True,True,True,False +2001:30::/28,Drone Remote ID Protocol Entity Tags (DETs) Prefix,[RFC9374],2022-12,N/A,True,True,True,True,False +2001:db8::/32,Documentation,[RFC3849],2004-07,N/A,False,False,False,False,False +2002::/16 [3],6to4,[RFC3056],2001-02,N/A,True,True,True,N/A [3],False +2620:4f:8000::/48,Direct Delegation AS112 Service,[RFC7534],2011-05,N/A,True,True,True,True,False +3fff::/20,Documentation,[RFC9637],2024-07,N/A,False,False,False,False,False +5f00::/16,Segment Routing (SRv6) SIDs,[RFC9602],2024-04,N/A,True,True,True,False,False +fc00::/7,Unique-Local,"[RFC4193] + [RFC8190]",2005-10,N/A,True,True,True,False [4],False +fe80::/10,Link-Local Unicast,[RFC4291],2006-02,N/A,True,True,False,False,True diff --git a/test/plausible/ip/tools_test.exs b/test/plausible/ip/tools_test.exs new file mode 100644 index 000000000000..a27ac3e8a8b8 --- /dev/null +++ b/test/plausible/ip/tools_test.exs @@ -0,0 +1,262 @@ +defmodule Plausible.IP.ToolsTest do + use ExUnit.Case, async: true + + alias Plausible.IP + + defp ip!(str) do + {:ok, ip} = :inet.parse_address(String.to_charlist(str)) + ip + end + + describe "reserved?/1 - IPv4" do + test "RFC 1918 private-use ranges" do + assert IP.Tools.reserved?(ip!("10.0.0.0")) + assert IP.Tools.reserved?(ip!("10.255.255.255")) + assert IP.Tools.reserved?(ip!("172.16.0.0")) + assert IP.Tools.reserved?(ip!("172.31.255.255")) + assert IP.Tools.reserved?(ip!("192.168.0.0")) + assert IP.Tools.reserved?(ip!("192.168.255.255")) + + refute IP.Tools.reserved?(ip!("172.15.255.255")) + refute IP.Tools.reserved?(ip!("172.32.0.0")) + refute IP.Tools.reserved?(ip!("11.0.0.0")) + refute IP.Tools.reserved?(ip!("192.167.255.255")) + refute IP.Tools.reserved?(ip!("192.169.0.0")) + end + + test "loopback 127.0.0.0/8" do + assert IP.Tools.reserved?(ip!("127.0.0.1")) + assert IP.Tools.reserved?(ip!("127.255.255.255")) + refute IP.Tools.reserved?(ip!("126.255.255.255")) + refute IP.Tools.reserved?(ip!("128.0.0.0")) + end + + test "this-network 0.0.0.0/8" do + assert IP.Tools.reserved?(ip!("0.0.0.0")) + assert IP.Tools.reserved?(ip!("0.255.255.255")) + end + + test "link-local 169.254.0.0/16" do + assert IP.Tools.reserved?(ip!("169.254.0.0")) + assert IP.Tools.reserved?(ip!("169.254.255.255")) + refute IP.Tools.reserved?(ip!("169.253.255.255")) + refute IP.Tools.reserved?(ip!("169.255.0.0")) + end + + test "shared address space (CGNAT) 100.64.0.0/10" do + assert IP.Tools.reserved?(ip!("100.64.0.0")) + assert IP.Tools.reserved?(ip!("100.127.255.255")) + refute IP.Tools.reserved?(ip!("100.63.255.255")) + refute IP.Tools.reserved?(ip!("100.128.0.0")) + end + + test "benchmarking 198.18.0.0/15" do + assert IP.Tools.reserved?(ip!("198.18.0.0")) + assert IP.Tools.reserved?(ip!("198.19.255.255")) + refute IP.Tools.reserved?(ip!("198.17.255.255")) + refute IP.Tools.reserved?(ip!("198.20.0.0")) + end + + test "documentation TEST-NET ranges" do + assert IP.Tools.reserved?(ip!("192.0.2.0")) + assert IP.Tools.reserved?(ip!("192.0.2.255")) + assert IP.Tools.reserved?(ip!("198.51.100.0")) + assert IP.Tools.reserved?(ip!("198.51.100.255")) + assert IP.Tools.reserved?(ip!("203.0.113.0")) + assert IP.Tools.reserved?(ip!("203.0.113.255")) + end + + test "reserved-for-future-use 240.0.0.0/4 (class E)" do + assert IP.Tools.reserved?(ip!("240.0.0.0")) + assert IP.Tools.reserved?(ip!("255.255.255.254")) + end + + test "limited broadcast 255.255.255.255/32" do + assert IP.Tools.reserved?(ip!("255.255.255.255")) + end + + test "multicast 224.0.0.0/4" do + assert IP.Tools.reserved?(ip!("224.0.0.0")) + assert IP.Tools.reserved?(ip!("224.0.0.1")) + assert IP.Tools.reserved?(ip!("239.255.255.255")) + refute IP.Tools.reserved?(ip!("223.255.255.255")) + end + + test "IETF protocol assignments 192.0.0.0/24 but not its globally reachable carve-outs" do + assert IP.Tools.reserved?(ip!("192.0.0.0")) + assert IP.Tools.reserved?(ip!("192.0.0.8")) + assert IP.Tools.reserved?(ip!("192.0.0.170")) + assert IP.Tools.reserved?(ip!("192.0.0.171")) + + refute IP.Tools.reserved?(ip!("192.0.0.9")) + refute IP.Tools.reserved?(ip!("192.0.0.10")) + end + + test "AS112/AMT anycast carve-outs are globally reachable, not reserved" do + refute IP.Tools.reserved?(ip!("192.31.196.1")) + refute IP.Tools.reserved?(ip!("192.52.193.1")) + refute IP.Tools.reserved?(ip!("192.175.48.1")) + end + + test "deprecated 6to4 relay anycast block reverted to ordinary space" do + refute IP.Tools.reserved?(ip!("192.88.99.1")) + assert IP.Tools.reserved?(ip!("192.88.99.2")) + end + + test "well-known public addresses are not reserved" do + refute IP.Tools.reserved?(ip!("8.8.8.8")) + refute IP.Tools.reserved?(ip!("1.1.1.1")) + refute IP.Tools.reserved?(ip!("93.184.216.34")) + refute IP.Tools.reserved?(ip!("172.217.0.0")) + end + end + + describe "reserved?/1 - IPv6" do + test "loopback and unspecified" do + assert IP.Tools.reserved?(ip!("::1")) + assert IP.Tools.reserved?(ip!("::")) + end + + test "unique local addresses fc00::/7" do + assert IP.Tools.reserved?(ip!("fc00::1")) + assert IP.Tools.reserved?(ip!("fd00::1")) + refute IP.Tools.reserved?(ip!("fe00::1")) + end + + test "link-local unicast fe80::/10" do + assert IP.Tools.reserved?(ip!("fe80::1")) + assert IP.Tools.reserved?(ip!("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) + refute IP.Tools.reserved?(ip!("fec0::1")) + end + + test "documentation ranges" do + assert IP.Tools.reserved?(ip!("2001:db8::1")) + assert IP.Tools.reserved?(ip!("3fff::1")) + end + + test "benchmarking and discard-only blocks" do + assert IP.Tools.reserved?(ip!("2001:2::1")) + assert IP.Tools.reserved?(ip!("100::1")) + end + + test "multicast ff00::/8" do + assert IP.Tools.reserved?(ip!("ff02::1")) + assert IP.Tools.reserved?(ip!("ff00::")) + refute IP.Tools.reserved?(ip!("feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) + end + + test "IETF protocol assignments 2001::/23 is reserved by default" do + assert IP.Tools.reserved?(ip!("2001:5::1")) + assert IP.Tools.reserved?(ip!("2001:1ff::1")) + end + + test "carve-outs inside 2001::/23 that are globally reachable are not reserved" do + refute IP.Tools.reserved?(ip!("2001:1::1")) + refute IP.Tools.reserved?(ip!("2001:1::2")) + refute IP.Tools.reserved?(ip!("2001:1::3")) + refute IP.Tools.reserved?(ip!("2001:3::1")) + refute IP.Tools.reserved?(ip!("2001:4:112::1")) + refute IP.Tools.reserved?(ip!("2001:20::1")) + refute IP.Tools.reserved?(ip!("2001:30::1")) + end + + test "TEREDO 2001::/32 is not treated as reserved" do + refute IP.Tools.reserved?(ip!("2001:0:4136:e378:8000:63bf:3fff:fdd2")) + end + + test "6to4 2002::/16 is not treated as reserved" do + refute IP.Tools.reserved?(ip!("2002:c000:0204::")) + end + + test "deprecated ORCHID block reverted to ordinary space" do + refute IP.Tools.reserved?(ip!("2001:10::1")) + end + + test "well-known public addresses are not reserved" do + refute IP.Tools.reserved?(ip!("2606:4700:4700::1111")) + refute IP.Tools.reserved?(ip!("2001:4860:4860::8888")) + end + end + + describe "reserved?/1 - IPv4-mapped IPv6 (::ffff:a.b.c.d)" do + test "delegates to the embedded IPv4 address instead of blanket-reserving ::ffff:0:0/96" do + assert IP.Tools.reserved?(ip!("::ffff:127.0.0.1")) + assert IP.Tools.reserved?(ip!("::ffff:10.0.0.1")) + assert IP.Tools.reserved?(ip!("::ffff:192.168.1.1")) + + refute IP.Tools.reserved?(ip!("::ffff:8.8.8.8")) + refute IP.Tools.reserved?(ip!("::ffff:1.1.1.1")) + end + + test "matches reserved?/1 applied directly to the embedded address" do + for str <- ["127.0.0.1", "10.1.2.3", "8.8.8.8", "192.0.2.1", "224.0.0.1"] do + {a, b, c, d} = v4 = ip!(str) + mapped = {0, 0, 0, 0, 0, 0xFFFF, a * 256 + b, c * 256 + d} + + assert IP.Tools.reserved?(mapped) == IP.Tools.reserved?(v4), + "expected ::ffff:#{str} to match reserved?/1 of #{str}" + end + end + end + + describe "allowed?/1" do + test "accepts already-parsed tuples" do + assert IP.Tools.allowed?({8, 8, 8, 8}) + refute IP.Tools.allowed?({127, 0, 0, 1}) + assert IP.Tools.allowed?({0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111}) + refute IP.Tools.allowed?({0, 0, 0, 0, 0, 0, 0, 1}) + end + + test "accepts binaries" do + assert IP.Tools.allowed?("8.8.8.8") + refute IP.Tools.allowed?("192.168.1.1") + assert IP.Tools.allowed?("2606:4700:4700::1111") + refute IP.Tools.allowed?("::1") + end + + test "disallows invalid inputs" do + refute IP.Tools.allowed?({1, 2, 3}) + refute IP.Tools.allowed?({1, 2, 3, 4, 5}) + refute IP.Tools.allowed?(nil) + refute IP.Tools.allowed?(:not_an_ip) + refute IP.Tools.allowed?("not an ip") + refute IP.Tools.allowed?("") + refute IP.Tools.allowed?("999.999.999.999") + end + end + + describe "ranges/0" do + test "every range is well formed" do + ranges = IP.Tools.ranges() + + assert length(ranges) > 30 + + for %{cidr: cidr, reserved: reserved, name: name} <- ranges do + assert is_binary(cidr) + assert is_binary(name) + assert is_boolean(reserved) + end + end + + test "is sorted from most to least specific prefix" do + prefix_lengths = + Enum.map(IP.Tools.ranges(), fn %{cidr: cidr} -> + [_addr, prefix] = String.split(cidr, "/") + String.to_integer(prefix) + end) + + assert prefix_lengths == Enum.sort(prefix_lengths, :desc) + end + + test "includes the supplemental multicast ranges" do + cidrs = Enum.map(IP.Tools.ranges(), & &1.cidr) + + assert "224.0.0.0/4" in cidrs + assert "ff00::/8" in cidrs + end + + test "excludes ::ffff:0:0/96 in favor of delegating to IPv4 rules" do + refute "::ffff:0:0/96" in Enum.map(IP.Tools.ranges(), & &1.cidr) + end + end +end