Skip to main content

README.md

# ShelllessRelease

Shell-free, distribution-hardened `mix release` for distroless images.

`mix release` emits a layered set of `#!/bin/sh` scripts as its launch interface
(`bin/<name>``releases/<v>/elixir``erts-*/bin/erl`), all of which
ultimately `exec` the native `erlexec`. On a distroless `:nonroot` image there is
no `/bin/sh`, so those scripts cannot run.

`ShelllessRelease` replaces them with a single native launcher (compiled from the
bundled `priv/launcher/start.c`) that `execve`s the BEAM directly and pre-starts
distribution itself, so the image needs no shell. The launcher is a multi-call
binary installed over the existing `bin/<name>` entry points, preserving the
interface so nothing downstream changes.

On top of being shell-free, it hardens the release:

- **Command whitelist** — a fixed set of verbs (the server plus your declared
  task verbs) baked into the launcher at build time. There is no generic `eval`,
  so an attacker who controls the container's args cannot run arbitrary code.
- **Pinned distribution port** — no ephemeral fallback, so the distribution port
  is firewallable.
- **Mandatory mutual-TLS distribution** — fail-closed without certs.
- **Fail-closed cookie strength check.**
- **EPMD-less mode** (optional) — drops the Erlang Port Mapper Daemon and port
  4369 entirely. See [`ShelllessRelease.EpmdLess`](https://hexdocs.pm/shellless_release/ShelllessRelease.EpmdLess.html).

## Installation

Add `shellless_release` to your deps in `mix.exs`:

```elixir
def deps do
  [
    {:shellless_release, "~> 0.1"}
  ]
end
```

## Usage

Add the hardening step to your release in `mix.exs`:

```elixir
def project do
  [
    # ...
    releases: [
      my_app: [
        steps: [:assemble, &ShelllessRelease.harden/1]
      ]
    ]
  ]
end
```

Configure it (all keys are optional):

```elixir
config :shellless_release,
  # Zero-arg task verbs -> compiled-in expressions (the whitelist). These
  # replace `bin/<verb>` shell overlays; each runs non-distributed.
  tasks: [
    migrate: "MyApp.Release.migrate()",
    seed: "MyApp.Release.seed()"
  ],
  # Pinned Erlang distribution port (build-time constant; default 24369).
  dist_port: 24369,
  # Require TLS distribution + the cert bundle (default true). When false,
  # distribution is cookie-only (only sensible for non-clustered apps).
  require_tls: true,
  # EPMD-less distribution (default true). Requires one node per IP.
  epmdless: true,
  # Extra entry-point names to install the launcher at, beyond "server" and
  # the task verbs (rarely needed).
  extra_entry_points: [],
  # Remove the generated shell launchers after install (default true).
  strip_shell_scripts: true
```

The launcher is compiled by this step **during** `mix release`, which is expected
to run in your Dockerfile's builder stage where a C compiler (`cc`/`gcc`) is
present. The library itself ships only C source — it never compiles anything at
`deps.compile` time.

At runtime the entry points behave exactly like a stock release:

```sh
bin/server      # boot the application
bin/migrate     # run a whitelisted task verb (non-distributed)
```

## Requirements

- Elixir `~> 1.15`
- A C compiler (`cc` or `gcc`) available in the release build stage — typically
  your Dockerfile builder image (e.g. `build-essential`).

## Documentation

Full documentation is on [HexDocs](https://hexdocs.pm/shellless_release). Start
with the [`ShelllessRelease`](https://hexdocs.pm/shellless_release/ShelllessRelease.html)
moduledoc for the launcher, and
[`ShelllessRelease.EpmdLess`](https://hexdocs.pm/shellless_release/ShelllessRelease.EpmdLess.html)
for EPMD-less distribution.

## License

MIT — see [LICENSE](LICENSE).