Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/iana-registry-check.yml
Original file line number Diff line number Diff line change
@@ -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": "<p>IANA IP registry is up to date.</p>"}'

- 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": "<p>⚠️ IANA IP registry check <a href=\"https://github.com/plausible/analytics/actions/runs/${{ github.run_id }}\">failed</a>.</p>"}'
56 changes: 56 additions & 0 deletions lib/ip/tools.ex
Original file line number Diff line number Diff line change
@@ -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
149 changes: 149 additions & 0 deletions lib/ip/tools/registry.ex
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions lib/mix/tasks/download_ip_registries.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule Mix.Tasks.DownloadIpRegistries do
Comment thread
aerosol marked this conversation as resolved.
@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
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's used elsewhere too but was never put up as an explicit dependency

{:nimble_totp, "~> 1.0"},
{:oban, "~> 2.20.1"},
{:observer_cli, "~> 1.7"},
Expand Down
27 changes: 27 additions & 0 deletions priv/ip/iana-ipv4-special-registry.csv
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions priv/ip/iana-ipv6-special-registry.csv
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading