# `Raxol.Core.Outbound`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/core/outbound.ex#L1)

The outbound target policy: vet a URL before anything opens a socket to it.

Anything in this repository that reaches a host the caller (or a model, or a
config file in a cloned workspace) had a say in goes through `vet/2` first.
Two of the five rules the policy states live here, because they need `:inet`
and nothing else:

1. The scheme and the port are allowed. `:schemes` defaults to `[:https]`
   and `:ports` to the ports an HTTP server listens on; a caller that
   documents `http`, or a fixed non-standard port, passes it explicitly.
   Without the port rule a vetted target is still an arbitrary-port prober
   against public hosts, because every other rule here judges the ADDRESS
   and none of them reads the port the caller is about to dial.
2. Every resolved address is outside the reject set. Both families are
   resolved, and the whole request is refused if ANY answer is rejected,
   rather than the first: a host with one public and one loopback record must
   not be reachable by luck of resolver ordering. A lookup that FAILED is not
   an empty answer: a nameserver that SERVFAILs the A query while answering
   AAAA would otherwise choose which half of its own records was vetted, so a
   partial failure refuses the whole request.

The third rule is the caller's, and this module is what makes it possible:
`vet/2` returns the addresses it checked, and those are the addresses the
caller MUST dial. Validating a hostname and then handing that hostname to an
HTTP client re-resolves it and reopens the window a changed DNS record walks
through. A caller that ignores `:addresses` and dials `:uri` has the same
rebinding gap it had before calling.

Rules 4 (no redirect following) and 5 (bounded time, size and concurrency)
belong to whoever owns the request, since neither is expressible here.

## The address list, and why it is bounded

`:addresses` carries the vetted answers, deduplicated, interleaved by family
and capped at 8. A caller tries them in turn the way `:gen_tcp` does when it
is handed a name, which is the failover pinning would otherwise lose.

Everything the resolver said is CHECKED; only the dial list is bounded, so
padding an RRset cannot walk a blocked address past the check by putting it
beyond the cap. The cap is there because `Raxol.Web3.Dial` gives EACH address
the full connect timeout (5000ms by default) in turn, so an uncapped list is
a multiplier a hostile authoritative nameserver controls: 64 padded answers
is 320 seconds of serial connects behind one call. Duplicates buy a repeated
attempt against the same dead socket, so they go too. Interleaving is what
makes the cap safe rather than a v6 outage: A-then-AAAA concatenated and cut
at 8 drops every AAAA answer for a host with 8 or more A records.

## The reject set

`blocked?/1` is the policy, and it is a predicate over `:inet` address
tuples rather than over strings, so a literal in a URL and a resolver answer
are judged by the same code. It refuses loopback, link-local, private,
carrier-grade NAT, unspecified, multicast and reserved ranges, the blocks
that exist only on paper (IETF protocol assignments, the v4 and v6
documentation ranges, benchmarking, discard-only) and the 6to4 relay
anycast address, plus every form that smuggles a v4 address through a v6
literal: IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64 (the /96 and
the RFC 8215 local-use prefix share one clause) and 6to4, which carries the
address in a different pair of groups than the rest. Teredo is refused
outright rather than decomposed, since it obfuscates the addresses it
tunnels. Anything that is not an address tuple is refused, so a malformed
answer fails closed.

The generic IPv6 clause is LAST, and the order is load-bearing rather than
tidy: it answers `false` for 6to4, NAT64 and every other prefix that embeds
or reserves an address, so moving it above them makes `2002:a9fe:a9fe::` —
a working route to the cloud metadata address — reachable, silently.

## Injecting a resolver

`:resolver` replaces `:inet.getaddrs/3` with any
`(charlist, :inet | :inet6, timeout -> {:ok, [address]} | {:error, term})`,
or the 2-arity form for a resolver that answers from a literal and needs no
budget. That is the seam a resolver which changes its answer between calls
is tested through, which is the test rule 3 exists for and which cannot be
written against the real resolver. An IP literal never reaches the resolver
at all, injected or not, so skipping DNS skips no part of the check.

# `address`

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

# `dns_reason`

```elixir
@type dns_reason() :: :nxdomain | {:lookup_failed, term()}
```

Why resolution produced no vetted address.

`:nxdomain` is an answer: the name has no address in either family.
`{:lookup_failed, reason}` is the absence of one, and the two are not
interchangeable — a breaker that quarantines an origin for a nonexistent
host should not quarantine it for a resolver hiccup.

# `reason`

```elixir
@type reason() ::
  :invalid_url
  | {:blocked_address, String.t()}
  | {:dns_failed, {String.t(), dns_reason()}}
```

# `resolver`

```elixir
@type resolver() ::
  (charlist(), :inet | :inet6 -&gt; {:ok, [address()]} | {:error, term()})
  | (charlist(), :inet | :inet6, timeout() -&gt;
       {:ok, [address()]} | {:error, term()})
```

# `vetted`

```elixir
@type vetted() :: %{uri: URI.t(), addresses: [address()], hostname: String.t()}
```

# `blocked?`

```elixir
@spec blocked?(address() | term()) :: boolean()
```

Whether an `:inet` address tuple is outside the public internet.

Public because this predicate is the policy, and is tested directly.

# `resolve`

```elixir
@spec resolve(String.t(), resolver(), keyword()) ::
  {:ok, [address()]} | {:error, dns_reason()}
```

Resolve a host to every address it answers with: `{:ok, addresses}` or
`{:error, reason}`.

An IP literal never reaches the resolver; a name is resolved over BOTH
families, because a host with only an AAAA record must not pass by an empty A
lookup. No address is judged here, and nothing is deduplicated or capped: a
caller reaching for this instead of `vet/2` is opting out of the policy and
gets exactly what the resolver said.

A family whose lookup FAILED is not a family with no records, and the failure
wins over the family that answered. See `t:dns_reason/0`.

`:timeout_ms` is the budget for the whole call, default `5000`. It is a
deadline rather than a per-family timeout: the AAAA lookup is given whatever
the A lookup left, so two slow families cost one budget and not two.

# `vet`

```elixir
@spec vet(String.t(), keyword()) :: {:ok, vetted()} | {:error, reason()}
```

Vet a URL: `{:ok, vetted}` or `{:error, reason}`.

`:hostname` is the host with any IPv6 brackets removed, which is the form
SNI, a certificate match and a `Host` header want. `:uri` is the parsed URL,
unmodified.

  * `:schemes` - allowed schemes as atoms, default `[:https]`
  * `:ports` - allowed ports, default `[80, 443, 8080, 8443]`. A port outside
    it is refused as `:invalid_url`, the same answer an unallowed scheme
    gets, because it is the same answer to a caller: not a target we dial.
  * `:resolver` - a `t:resolver/0`, default `&:inet.getaddrs/3`
  * `:timeout_ms` - the total budget for resolution, default `5000`. Both
    families are looked up under it: the second gets whatever the first left,
    so the whole of `vet/2` returns within it plus the URL parse.

---

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