ExSandbox.Egress.Allowlist (ExSandbox v1.0.1)

Copy Markdown View Source

Turns a tenant project's configured destinations into the form ExSandbox.Egress.Policy enforces (005 T060a2, 013-FR-014b).

Why this is a separate module from Policy

Policy answers "is this destination permitted?" against a list already known to be well-formed. This module answers the earlier and more dangerous question: what does an operator's configuration actually mean? Those fail in opposite directions, and keeping them together would hide it.

A wrong answer in Policy refuses something it should permit — visible immediately, because the permitted-destination check goes red. A wrong answer here can invent permission that the operator never granted, and nothing downstream can tell: Policy enforces whatever list it is handed, faithfully.

Malformed input is refused, never dropped

⚠️ The tempting implementation is Enum.filter/2 + Enum.map/2, and it is the one that produces a silent breach. Filtering skips entries that do not parse, so a project configured with

["api.example.com:443", "10.0.0.5"]

— where the second entry is missing its port — yields a shorter allowlist that still looks correct and enforces the entries that did parse. The operator sees egress working, and never learns that one rule was discarded. The mirror case is worse: a config whose entries all fail to parse filters down to [], which Policy treats as default-deny. That one at least fails closed, but it presents as "the allowlist is being enforced" rather than "your configuration was not understood".

So parse/1 returns {:error, {:invalid_entries, [...]}} naming every entry it could not read, and provisioning refuses. A sandbox is not provisioned with a partial interpretation of its network policy.

An empty allowlist is legitimate, and distinct from an unreadable one

parse([]) is {:ok, []} — a project permitted to reach nothing, which is a coherent and useful configuration (FR-011a's default-deny with nothing added). It must not be conflated with a configuration that failed to parse, which is why the error case is a tuple rather than an empty list.

The host's aliases arrive as data (029 T009a, FR-015, D103)

FR-015 requires excluding every alias for the host, and on the pasta mechanism the mapped gateway address is a second name for the host that is not the string 127.0.0.1. But this module transfers whole to a future container mechanism precisely because it names no mechanism, so no pasta concept may live in it. Both hold only if the alias set is handed in: parse/2 receives host_aliases and stays a pure policy function.

⚠️ Two shapes were considered and ruled out. A capability query — this module asking a mechanism what the host's addresses are — is exactly the re-coupling the transfer argument forbids. A configured constant drifts from the address actually in use, and that drift is the defect: the value would be right in the config file and wrong in the namespace, with every parse-time test green.

So this module still knows nothing about pasta, Docker, or gateways. It knows that its caller may hand it a list of things that are the host, and it refuses entries naming any of them with :host_alias — a class of its own, so the refusal says why rather than "invalid".

parse/1 is parse/2 with an empty alias list and behaves exactly as before.

Accepted forms

formmeaning
"host:443"that host on that port only
"host:*"that host on every port
{"host", 443}already-parsed, passes through
{"host", :any_port}already-parsed, passes through

⚠️ There is deliberately no bare "host" form. It reads as "this host", but it has to resolve to either one port or all of them, and the safe reading (:any_port) is the permissive one. An operator who means every port must write * and see themselves write it.

Refused address classes (029-FR-015)

An entry may be perfectly readable and still name somewhere a sandbox must never be handed. The allowlist is the tenant's outward reach; an entry naming the host the sandbox runs on, or the operator's own private network, is a hole in the isolation boundary rather than a destination.

These are refused at parse time, before a sandbox exists:

classwhat it covers
:loopback127.0.0.0/8, IPv6 ::1, and the reserved name localhost
:rfc1918_private10/8, 172.16/12, 192.168/16
:link_local169.254.0.0/16, IPv6 fe80::/10
:cloud_metadata169.254.169.254 exactly
:unique_localIPv6 fc00::/7
:unspecified0.0.0.0/8 and IPv6 ::

⚠️ :cloud_metadata is a subset of :link_local and is named separately anyway. Refusing it as "link-local" is correct and useless: an operator who wrote 169.254.169.254:80 was reaching for the instance credentials endpoint, and telling them the address is link-local does not tell them the system knows what they were reaching for. FR-014 asks that a refusal be actionable, and the class is the only part of the message that carries what to do about it.

⚠️ The refusal names the class, and that is the point of the whole guard. {:invalid_entries, ["127.0.0.1:80"]} is indistinguishable from a typo: an operator reads "invalid", re-checks their spelling, finds it correct, and files a bug against the parser. {:refused_entries, [{"127.0.0.1:80", :loopback}]} says the entry was understood and declined. Those are different conversations, so they are different error terms.

Why the refusal is a separate error from the unreadable one

⚠️ Unreadable entries are reported first and alone, and this is forced rather than chosen: classification needs a host, and an entry that did not parse has no host to classify. "10.0.0.5" (missing its port) is visibly RFC1918 to a human and is nonetheless reported as unreadable, because the parser reaches :error before any address is in hand. An operator with both kinds of problem fixes syntax first and sees the policy refusals on the next attempt. That is two round trips, and it is the cost of not guessing at the meaning of an entry that failed to parse.

What this guard is not

⚠️ This is the static address classes only. 127.0.0.1 is not the host's only name — a mapped gateway address handed to the namespace is a second one, and it is not a constant this module could know. Nothing here consults a running mechanism, reads configuration, or resolves a hostname. An entry naming a hostname that resolves to 10.0.0.5 parses clean here; catching that is a connect-time question, not a parse-time one.

Summary

Types

The class an entry was refused for (029-FR-015).

An entry as a project's settings may express it.

Why parsing refused.

A thing that is the host, as a caller may express it.

Functions

The class host would be refused for, or nil if it names nowhere excluded.

Renders a parse/2 error as sentences naming every entry and its class.

Why an entry of class was refused, as a clause a person can act on.

Parses a project's configured destinations into Policy.destination() values.

Types

class()

@type class() ::
  :loopback
  | :rfc1918_private
  | :link_local
  | :cloud_metadata
  | :unique_local
  | :unspecified
  | :host_alias

The class an entry was refused for (029-FR-015).

⚠️ Carried in the error so the refusal is actionable. See the moduledoc table: a refusal that says only "invalid" cannot be told from a typo, and FR-014 requires an operator be able to act on it without reading code.

:host_alias is the odd one out and deliberately so. Every other class is a property of the address itself, knowable from the string. Whether an address is the host depends on the mechanism the caller runs, so it arrives as data (see parse/2) rather than being recognised here.

entry()

An entry as a project's settings may express it.

error()

@type error() ::
  {:invalid_entries, [term()]} | {:refused_entries, [{term(), class()}]}

Why parsing refused.

:invalid_entries names every entry that could not be read. :refused_entries names every entry that read cleanly and named an address class a sandbox may not be pointed at, each paired with the class it was refused for.

host_alias()

@type host_alias() :: String.t() | :inet.ip_address()

A thing that is the host, as a caller may express it.

An address ("10.0.0.1", {10, 0, 0, 1}) or a name ("host.docker.internal"). Ports are not part of an alias: a destination is the host, or it is not, and naming a port would permit every other one.

Functions

classify(host, host_aliases \\ [])

@spec classify(term(), [host_alias()]) :: class() | nil

The class host would be refused for, or nil if it names nowhere excluded.

⚠️ Public because 029-FR-015's exclusion applies to resolved answers as well as to written entries, and the two must name the same class for the same address. ExSandbox.Egress.Resolver runs every answer it is about to record through this function, so a name whose zone points at 127.0.0.1 produces :loopback at connect time exactly as writing 127.0.0.1 produces :loopback at parse time. A second classifier would drift, and the drift would show up as one surface refusing what the other permits, with no test able to see both.

host may be an address tuple or a string; host_aliases has the same meaning as in parse/2.

describe(arg)

@spec describe(error()) :: String.t()

Renders a parse/2 error as sentences naming every entry and its class.

This is what 029-FR-014 asks for and what nothing produced: the answer to "why was this refused?" in a form that can be put in front of a person who is not going to read address_class/1.

⚠️ The class atom is printed literally, alongside its prose. Not decoration: :cloud_metadata is the string an operator greps for, pastes into a bug report, and matches against this module's own table. A sentence alone would be readable and unsearchable.

⚠️ Every refused entry is listed, not the first. The refusal is already the second round trip for an operator who also had unreadable entries (see the moduledoc); making them fix refusals one per provision would be a third, fourth and fifth.

describe_class(atom)

@spec describe_class(class()) :: String.t()

Why an entry of class was refused, as a clause a person can act on.

⚠️ This is the half of 029-FR-015 that had no reader, and the class existed for it. parse/2 has named a class since 029 T008 and every caller propagated the tuple opaquely, so what reached an operator was "provisioning failed" — the exact sentence the class was added to replace. A class nobody renders is the same defect as a check that cannot fail.

The sentence lives here, beside the classifier, rather than at whichever surface happens to show it. Two surfaces writing their own would be two vocabularies for one set of atoms, and the one nobody reads is the one that stops matching address_class/1.

⚠️ The atom is not in the sentence — describe/1 puts it there. This is the prose half only, so a caller rendering somewhere the atom would be noise can leave it out.

parse(entries, host_aliases \\ [])

@spec parse([entry()] | nil, [host_alias()]) ::
  {:ok, [ExSandbox.Egress.Policy.destination()]} | {:error, error()}

Parses a project's configured destinations into Policy.destination() values.

Returns {:error, {:invalid_entries, entries}} if any entry is unreadable — see the moduledoc for why this is not a filter.

Returns {:error, {:refused_entries, [{entry, class}]}} if every entry read cleanly but one or more names a refused address class (029-FR-015).

host_aliases is the set of things that are the host on whatever mechanism the caller runs, handed in as data. See the moduledoc for why it is not queried for.