Skip to main content

lib/shellless_release.ex

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