defmodule ShelllessRelease do
@moduledoc """
Build a shell-free, distribution-hardened `mix release` for distroless images.
`mix release` emits a layered set of `#!/bin/sh` scripts as the 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.
This library replaces them with a single native launcher (compiled from the
bundled `priv/launcher/start.c`) that `execve`s the BEAM directly and
pre-starts epmd 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. It also enforces:
* a fixed command **whitelist** (server + your declared task verbs) — no
generic `eval`, so controlling the container's args can't run arbitrary
code;
* a **pinned distribution port** (no ephemeral fallback → firewallable);
* **mandatory mutual-TLS distribution** (fail-closed without certs);
* a **fail-closed cookie** strength check.
## Usage
Add to your release in `mix.exs`:
releases: [
my_app: [
steps: [:assemble, &ShelllessRelease.harden/1]
]
]
Configure it (all keys optional except `:tasks` if you want task verbs):
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,
# 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 runs in your
Dockerfile's builder stage (where a C compiler is present). The library itself
ships only source — it never compiles C at `deps.compile` time.
See `ShelllessRelease.EpmdLess` for EPMD-less distribution (drops port 4369).
"""
@priv_launcher "launcher/start.c"
@priv_tls_config "dist/inet_tls.config"
@doc """
Post-`:assemble` release step. Compiles the launcher, installs it over the
entry points, stages the TLS config, and strips the shell launchers.
"""
@spec harden(Mix.Release.t()) :: Mix.Release.t()
def harden(%Mix.Release{} = release) do
cfg = config()
bin_dir = Path.join(release.path, "bin")
launcher = compile_launcher(release, cfg)
install_entry_points(launcher, bin_dir, entry_points(cfg))
stage_tls_config(release, cfg)
if cfg.strip_shell_scripts, do: strip_shell_scripts(release)
File.rm(launcher)
log(release, "shell-free launcher installed at bin/{#{Enum.join(entry_points(cfg), ",")}}")
release
end
# --- config --------------------------------------------------------------
defp config do
%{
tasks: Application.get_env(:shellless_release, :tasks, []),
dist_port: Application.get_env(:shellless_release, :dist_port, 24369),
require_tls: Application.get_env(:shellless_release, :require_tls, true),
epmdless: Application.get_env(:shellless_release, :epmdless, true),
extra_entry_points: Application.get_env(:shellless_release, :extra_entry_points, []),
strip_shell_scripts: Application.get_env(:shellless_release, :strip_shell_scripts, true),
certs_dir: Application.get_env(:shellless_release, :certs_dir, "dist-certs")
}
|> validate!()
end
defp validate!(cfg) do
unless is_integer(cfg.dist_port) and cfg.dist_port > 1024 and cfg.dist_port < 32768 do
Mix.raise("""
shellless_release: :dist_port must be an integer in 1025..32767 (above
privileged ports, below the Linux ephemeral range so it can't collide
with kernel-assigned ports). Got: #{inspect(cfg.dist_port)}
""")
end
for {verb, expr} <- cfg.tasks do
unless valid_verb?(verb) do
Mix.raise("shellless_release: task verb #{inspect(verb)} must match [a-z][a-z0-9_]*")
end
unless is_binary(expr) and expr != "" do
Mix.raise("shellless_release: task #{inspect(verb)} expression must be a non-empty string")
end
end
cfg
end
defp valid_verb?(verb) do
s = to_string(verb)
s != "server" and s != "start" and Regex.match?(~r/^[a-z][a-z0-9_]*$/, s)
end
defp entry_points(cfg) do
["server"] ++ Enum.map(cfg.tasks, fn {v, _} -> to_string(v) end) ++
Enum.map(cfg.extra_entry_points, &to_string/1)
end
# --- compile -------------------------------------------------------------
defp compile_launcher(release, cfg) do
source = priv_path(@priv_launcher)
cc = System.find_executable("cc") || System.find_executable("gcc")
unless cc do
Mix.raise("""
shellless_release: no C compiler (cc/gcc) on PATH. The launcher is
compiled during `mix release`, which is expected to run in a build
environment that has one (e.g. your Dockerfile builder stage with
build-essential).
""")
end
# Generate the task whitelist header from config. Baking it at compile time
# is the security property: the verb->expression map cannot be altered at
# runtime.
header = generate_tasks_header(cfg.tasks)
header_dir = Path.join(System.tmp_dir!(), "shellless-#{release.name}-#{release.version}")
File.mkdir_p!(header_dir)
File.write!(Path.join(header_dir, "shellless_tasks.h"), header)
out = Path.join(header_dir, "launcher")
args =
[
"-O2",
"-Wall",
"-D_FORTIFY_SOURCE=2",
"-fstack-protector-strong",
"-I",
header_dir,
~s(-DRELEASE_NAME="#{release.name}"),
"-DDIST_PORT=#{cfg.dist_port}"
] ++
if(cfg.require_tls, do: ["-DREQUIRE_TLS=1"], else: []) ++
if(cfg.epmdless, do: ["-DEPMDLESS=1"], else: []) ++
["-o", out, source]
case System.cmd(cc, args, stderr_to_stdout: true) do
{_out, 0} ->
out
{output, status} ->
Mix.raise("shellless_release: compiling the launcher failed (exit #{status}):\n\n#{output}")
end
end
@doc false
# Emit `#define TASKS_INIT { "verb", "expr" }, ...` with C-escaped strings.
def generate_tasks_header(tasks) do
rows =
Enum.map_join(tasks, " \\\n", fn {verb, expr} ->
~s( { "#{c_escape(to_string(verb))}", "#{c_escape(expr)}" },)
end)
"""
/* GENERATED by ShelllessRelease.generate_tasks_header/1. Do not edit. */
#ifndef SHELLLESS_TASKS_H
#define SHELLLESS_TASKS_H
#define TASKS_INIT \\
#{rows}
#endif
"""
end
defp c_escape(s) do
s
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
# --- install -------------------------------------------------------------
defp install_entry_points(launcher, bin_dir, names) do
File.mkdir_p!(bin_dir)
for name <- names do
target = Path.join(bin_dir, name)
File.rm(target)
File.rm(Path.join(bin_dir, name <> ".bat"))
File.cp!(launcher, target)
File.chmod!(target, 0o755)
end
end
# Stage the SSL dist options file at <root>/dist/inet_tls.config (where the
# launcher looks). The cert bundle itself is mounted at runtime (a Secret),
# not shipped in the image.
defp stage_tls_config(release, cfg) do
if cfg.require_tls do
dist_dir = Path.join(release.path, "dist")
File.mkdir_p!(dist_dir)
File.cp!(priv_path(@priv_tls_config), Path.join(dist_dir, "inet_tls.config"))
end
end
# Remove every generated #!/bin/sh launcher so the image is honestly
# shell-free: the release CLI dispatcher (bin/<release_name>), the copied
# elixir/iex launchers, and the sourced env.sh, all under releases/<v>/.
defp strip_shell_scripts(release) do
bin = Path.join(release.path, "bin")
vpath = release.version_path
File.rm(Path.join(bin, to_string(release.name)))
File.rm(Path.join(bin, "#{release.name}.bat"))
for f <- ~w(elixir iex env.sh env.bat) do
File.rm(Path.join(vpath, f))
end
:ok
end
# --- helpers -------------------------------------------------------------
defp priv_path(rel) do
Application.app_dir(:shellless_release, Path.join("priv", rel))
end
defp log(release, msg) do
unless release.options[:quiet], do: Mix.shell().info([:green, "* ", :reset, msg])
end
end