# `Ankusa.Net`
[🔗](https://github.com/jamescarr/ankusa/blob/ankusa-v0.7.0/packages/ankusa/lib/ankusa/net.ex#L1)

IP addresses in the form the route-management hot path and the `cidr` package
already share: an `:inet.ip_address()` tuple — a 4-tuple for IPv4, an 8-tuple
for IPv6.

`:inet` tuples are the boundary format (a `Plug.Conn.remote_ip`, an
`:inet.ntoa/1` result) and the representation every CIDR rule carries, so no
conversion happens on the way into a membership test. The point of the one
normalization below is one rule, applied once:

  * an IPv4-mapped IPv6 address — `::ffff:1.2.3.4`, what a dual-stack listener
    reports for an IPv4 client — becomes `{1, 2, 3, 4}`, so an IPv4 client
    matches an IPv4 rule and never an IPv6 one (and vice versa).

A genuine IPv6 address stays an 8-tuple. Nothing here normalizes on the way
*into* the rule scan, so a caller that hands it an unnormalized address gets a
miss, not a wrong match.

The other half of the layer is `parse_cidr/1`: a CIDR string as an operator
writes it — in YAML config or in an admin API payload — becomes a `%CIDR{}`.
It never raises and never accepts an IPv4-mapped IPv6 range, which could not
match a normalized address anyway. Rule values and `X-Forwarded-For` entries
are attacker-reachable, so `parse/1` reads a binary as bytes rather than as
UTF-8 text: junk is an `:error`, never a raise that would land in the guard.

# `cidr`

```elixir
@type cidr() :: %CIDR{first: term(), hosts: term(), last: term(), mask: term()}
```

A parsed CIDR range (`CIDR.parse/1`), the rule shape every IP list carries.

# `ip`

```elixir
@type ip() :: :inet.ip_address()
```

A normalized IP address: an `:inet.ip_address()` tuple.

# `mapped_range_hint`

```elixir
@spec mapped_range_hint() :: String.t()
```

What to tell an operator whose CIDR was refused with `{:error, :mapped_range}`.

# `normalize`

```elixir
@spec normalize(ip()) :: ip()
```

Collapse an IPv4-mapped IPv6 address to its IPv4 form; pass everything else
through unchanged.

# `parse`

```elixir
@spec parse(binary()) :: {:ok, ip()} | :error
```

Parse an address in any `:inet.parse_address/1` syntax (`"10.0.0.1"`,
`"::1"`, `"::ffff:10.0.0.1"`), normalized.

`:inet.parse_address/1` is the whole grammar: it is strict about trailing
junk (`"10.0.0.1abc"`, `"1.2.3.4.5"`) but accepts inet's historical IPv4
shorthand (`"10"` is `0.0.0.10`, `"0x7f.1"` is `127.0.0.1`) and ignores a
scope suffix (`"fe80::1%eth0"`), so a config typo like `"10"` means
`0.0.0.10` rather than an error. `:error` for anything it rejects.

The binary reaches inet as bytes (`:binary.bin_to_list/1`), not as UTF-8 text
(`String.to_charlist/1`, which raises on invalid UTF-8): a forwarded-for
entry is attacker-reachable, and a raise here is a 500 in the guard rather
than an `:error` the guard denies on.

# `parse_cidr`

```elixir
@spec parse_cidr(binary()) :: {:ok, cidr()} | {:error, :mapped_range | String.t()}
```

Parse a CIDR string — `"10.0.0.0/8"`, `"::1"`, `"2001:db8::/32"` — the one
entry point for every CIDR an operator writes (`routes.trusted_proxies`, a
route's `ip_rules`).

`CIDR.parse/1` is the parser; this wrapper covers what it leaves open:

  * it never raises, so a config typo or a bad API payload is something to
    report rather than a crash (`CIDR.parse/1` sends the address through
    `String.to_charlist/1`, which raises on a non-UTF-8 binary);
  * it refuses an IPv4-mapped IPv6 range — any range inside `::ffff:0:0/96`,
    e.g. `::ffff:10.0.0.0/104`. Addresses are normalized to IPv4 before
    matching (`normalize/1`), so such a range can never match one; as a
    *deny* rule it would silently let its own traffic through. Ranges that
    merely contain mapped space (`::/0`, `::/80`) are fine.

`{:error, message}` for any non-binary and for a string that is not a CIDR.
`{:error, :mapped_range}` for a mapped range: it parses fine, so the operator
needs telling why it was refused, and `mapped_range_hint/0` is that sentence.
Keeping it a distinct reason lets a caller add the explanation only there and
leave its ordinary "not a CIDR" message alone. A host whose bits fall outside
its mask (`"10.0.0.5/8"`) is masked off, as `CIDR.parse/1` already does.

# `to_string`

```elixir
@spec to_string(ip()) :: String.t()
```

Render `ip` in its canonical `:inet.ntoa/1` form.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
