ExSandbox.Egress.LaunchPlan (ExSandbox v1.0.0)

Copy Markdown View Source

The ordered steps that put a tenant process inside a policed namespace (005 T060a3, contracts/egress.md §Lifecycle).

The order, and why it inverted

The first version of this module created a named namespace, configured it, and had the tenant join it — all before the tenant started. That order is not reachable: pasta cannot join a namespace made by ip netns add (measured, egress-path-measurements.md defect 3), and a tenant inside pasta's namespace has no CAP_NET_ADMIN to configure it from within (defect 4). The reachable order is:

  1. launchpasta creates the namespace, configures it, and starts the tenant inside it,
  2. find the holder — the tenant's pid, not pasta's (see ExSandbox.Egress.Pasta),
  3. police — the host installs the redirect into that namespace.

⚠️ The tenant is running, unpoliced, between steps 1 and 3. That window is real and cannot be closed by reordering, because the namespace does not exist until the tenant is in it. It is closed instead by what pasta gives the namespace: the tenant's only route out is pasta itself, and until the redirect lands, Egress.Acceptor is not listening, so a connection in that window reaches nothing. The window fails closed, and ExSandbox.Egress.Verification exists to confirm that rather than assume it.

Why this is a plan rather than a launch

It composes commands and does not run them. The value of stopping here is that the ordering — the part that cannot be checked by inspecting any single command — becomes testable on a host where none of these commands can execute, which is every developer machine that is not Linux.

Why a missing --unshare-net is refused

This module replaces an existing confinement. Handed a command that never confined the network, it has no way to tell "already converted" from "never confined", and the second is a command that would launch a tenant with the host's own network. Refusing is the only answer that cannot be wrong.

⚠️ --unshare-net is removed, not supplemented. Keeping it would put the tenant in a fresh empty namespace while pasta configured a different one — isolation restored silently, policy discarded, and every denial check still green, because an empty namespace denies everything too.

Summary

Types

Why a plan could not be built.

t()

Functions

Builds the plan for one sandbox, or refuses.

Where pasta records its host-side pid for this sandbox.

The redirect steps for a plan, once the namespace holder is known.

Types

refusal()

@type refusal() :: :no_network_confinement | :no_pool_port | :no_privilege_drop

Why a plan could not be built.

t()

@type t() :: %ExSandbox.Egress.LaunchPlan{
  pasta_command: [String.t()],
  pidfile: String.t(),
  pool_port: :inet.port_number(),
  resolver: ExSandbox.Egress.Netns.resolver(),
  source_key: ExSandbox.Egress.Policy.source_key(),
  tenant_command: [String.t()]
}

Functions

build(source_key, pool_port, tenant_command, opts \\ [])

@spec build(
  ExSandbox.Egress.Policy.source_key(),
  :inet.port_number(),
  [String.t()],
  keyword()
) ::
  {:ok, t()} | {:error, refusal()}

Builds the plan for one sandbox, or refuses.

tenant_command is the fully composed confinement command — the output of ExSandbox.Hardening.Linux.build_command/2 — which this rewrites to run under pasta instead of unsharing an empty namespace.

Options

  • :pidfile — where pasta records its host-side pid.
  • :resolver{address, port} a sandbox may send UDP to, or nil for none. Defaults to ExSandbox.Egress.Resolver.resolver_address/0. ⚠️ nil drops all UDP, which includes DNS. That is default-deny and not a degradation, but it is not a resolver either: pass nil only when the sandbox is meant to have no name resolution at all. ⚠️ An address that cannot be read raises — see the note at the call site.

default_pidfile(arg)

@spec default_pidfile(ExSandbox.Egress.Policy.source_key()) :: String.t()

Where pasta records its host-side pid for this sandbox.

⚠️ The file contains pasta's pid, not the tenant's. See ExSandbox.Egress.Pasta for why the difference is a silent catastrophe rather than a detail.

redirect_steps(launch_plan, holder_pid)

@spec redirect_steps(t(), pos_integer()) :: [[String.t()]]

The redirect steps for a plan, once the namespace holder is known.

⚠️ Deliberately not a field on the struct. The holder pid does not exist when the plan is built — the namespace it names is created by running the plan. A field would have to be nil at build time and filled in later, and the failure mode of that shape is a plan whose steps were composed against nil and quietly target the wrong namespace.

Requiring the pid as an argument means there is no way to ask for these commands without having one.