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

The egress policy: which URLs Ankusa may send a hook to (CWE-918).

An HTTP sink forwards the raw webhook body to a URL somebody configured — an
operator in YAML, or a tenant through the admin API. Without a policy that URL
may be `http://127.0.0.1:4003/...` (Ankusa's own admin listener), the cloud
metadata service (`http://169.254.169.254/...`), or any service on the
private network. `Ankusa.Sink.Http` therefore calls `validate_target/3` (via
`pin/3`) on every attempt, so a name that resolves somewhere else on the
next retry is checked again, and a deny is a permanent failure.

## The policy

`Ankusa.Config`'s `egress` section (YAML `egress:`):

  * `:allow_insecure_http` — `false`: only `https` URLs. `true` also allows
    `http`, which carries the hook in the clear
  * `:deny_cidrs` — an address inside any of these is refused. Default:
    loopback, RFC 1918, carrier-grade NAT, link-local (the metadata service),
    unique-local and link-local IPv6, and the unspecified addresses
  * `:allow_cidrs` — a deny exception: an address inside one of these passes
    even when a deny CIDR also holds it (`["10.20.0.0/16"]` for the one
    internal network sinks live on)
  * `:allow_hosts` — hostnames exempt from the CIDR check, for sinks reached
    by service name (`["worker"]`). The name is still resolved and pinned
  * `:deny_hosts` — hostnames refused before any lookup; wins over
    `:allow_hosts`. Default: `["metadata.google.internal"]`

A name is resolved for both address families with the OTP resolver
(`:inet.getaddrs`), and the
target is refused when **any** of its addresses is denied and not allowed: a
name with one public and one private record is a rebinding setup, not a
multi-homed service. IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1`) are
matched as the IPv4 address they stand for.

## Pinning

`pin/3` returns what to send instead of the URL it was given: the URL names the
checked IP address and a `host` header carries the name, so the connection
cannot go anywhere the check did not look and nothing is left to rebind. For
`https` the connection options also carry `hostname: name`, which Mint uses for
SNI and for the certificate check, so the peer must still present a certificate
valid for the *name* (peer verification cannot be switched off without
`allow_insecure_tls`, see `Ankusa.HttpClient`).

Every connection a pool opens to the pinned URL goes to that address:
`Ankusa.HttpClient.Pools` keeps one pool per pinned address and `hostname`, and
`pin/3` runs again, resolving the name afresh, before every request. A name that
later resolves to a denied address is refused on that request, before any pool
is touched; one that resolves to another public address gets a pool of its own,
and the old one is swept once idle.

Reasons in `{:error, {:egress_denied, reason}}` carry the host and the
offending address, never the URL: it may hold credentials.

# `pinned`

```elixir
@type pinned() :: %{
  url: String.t(),
  headers: [{String.t(), String.t()}],
  connect_options: keyword()
}
```

What to send: `url`, extra request `headers`, and `connect_options` for the client.

# `policy`

```elixir
@type policy() :: %{
  optional(atom()) =&gt; term(),
  allow_insecure_http: boolean(),
  deny_cidrs: [String.t()],
  allow_cidrs: [String.t()],
  allow_hosts: [String.t()],
  deny_hosts: [String.t()]
}
```

# `reason`

```elixir
@type reason() ::
  {:url, String.t()}
  | {:scheme, String.t()}
  | {:host, String.t()}
  | {:address, String.t(), String.t()}
  | {:resolve, String.t(), term()}
  | {:policy, String.t()}
```

# `resolver`

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

# `default_policy`

```elixir
@spec default_policy() :: policy()
```

The default policy: `https` only, every private, loopback and link-local range denied.

# `pin`

```elixir
@spec pin(String.t(), policy(), keyword()) ::
  {:ok, pinned()} | {:error, {:egress_denied, reason()}}
```

`validate_target/3`, returning what to send: see "Pinning".

Takes `validate_target/3`'s options.

# `validate_config!`

```elixir
@spec validate_config!(Ankusa.Config.t()) :: :ok
```

Raise `ArgumentError`, naming the key, unless `config.egress` is a well-formed
policy (`Ankusa.Config` calls it, and so does the instance at boot), and unless
every `Ankusa.Sink.Http` the config names — lifecycle sinks and the static
sources — passes `Ankusa.Sink.Http.validate_opts!/1`.

# `validate_policy!`

```elixir
@spec validate_policy!(term()) :: :ok
```

Raise `ArgumentError` unless `policy` is a well-formed policy map. See `validate_config!/1`.

# `validate_target`

```elixir
@spec validate_target(String.t(), policy(), keyword()) ::
  :ok | {:error, {:egress_denied, reason()}}
```

Check `url` against `policy`: `:ok`, or `{:error, {:egress_denied, reason}}`.

Options: `:resolver` (a `(charlist, family -> {:ok, [ip]} | {:error, reason}`
function, default `:inet.getaddrs` with the timeout) and `:timeout` (the lookup deadline in
milliseconds, default `5000`).

---

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