Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions lib/mix/tasks/firecracker.install.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
defmodule Mix.Tasks.Firecracker.Install do
@shortdoc "Download, verify, and install the pinned Firecracker release"
@moduledoc """
Downloads, verifies, and installs the pinned Firecracker release (v1.16.0)
for the current CPU architecture.

mix firecracker.install [--prefix DIR]

Steps performed:

1. Detects the CPU architecture (`x86_64` or `aarch64`).
2. Downloads the release tarball and verifies its SHA-256 checksum.
3. Extracts the tarball, then copies the binaries to `<prefix>/firecracker`
and `<prefix>/jailer` using the **bare basenames** `firecracker` and
`jailer`. The setuid helper validates binaries via `SafeBin<"firecracker">`
and `SafeBin<"jailer">`, which match on basename only — version-stamped
names such as `firecracker-v1.16.0-x86_64` are rejected unconditionally.
4. Marks both binaries executable (`0o755`).
5. Prints the `/etc/hyper/config.toml` snippet the operator needs to paste.

This task installs **unprivileged** binaries and prints configuration.
Privilege at runtime is handled by `hyper-suidhelper` (the setuid helper).
This task does **not** setuid `firecracker` or `jailer`. Install and setuid
the helper separately with `mix suidhelper.install`.

## Options

* `--prefix DIR` — installation directory (default: `/opt/firecracker`).

## Security requirements

After installing, ensure:

* The binaries are root-owned and **not** group- or world-writable.
The suidhelper refuses binaries with loose permissions.
* `/etc/hyper/config.toml` is root-owned with mode `0644`.
"""

use Mix.Task

@version "1.16.0"
@default_prefix "/opt/firecracker"

@impl Mix.Task
@spec run([String.t()]) :: :ok
def run(argv) do
{opts, _rest, _invalid} = OptionParser.parse(argv, strict: [prefix: :string])
prefix = Keyword.get(opts, :prefix, @default_prefix)

arch = detect_arch!()

case Application.ensure_all_started(:req) do
{:ok, _} -> :ok
{:error, {reason, app}} -> Mix.raise("Cannot start HTTP client #{app}: #{inspect(reason)}")
end

install!(release_for(arch), prefix)
print_config(prefix)
end

defp detect_arch! do
case Sys.Arch.current() do
{:ok, arch} ->
arch

{:error, {:unsupported_arch, raw}} ->
Mix.raise(
"Unsupported CPU architecture #{inspect(raw)}; " <>
"Firecracker supports x86_64 and aarch64."
)
end
end

defp release_for(:x86_64) do
%{
url:
"https://github.com/firecracker-microvm/firecracker/releases/download/" <>
"v#{@version}/firecracker-v#{@version}-x86_64.tgz",
sha256: "bd04e26952d4e158085778c6230a0b383d2619c319182e27eaa9d61a212e92d6",
firecracker_path: "release-v#{@version}-x86_64/firecracker-v#{@version}-x86_64",
jailer_path: "release-v#{@version}-x86_64/jailer-v#{@version}-x86_64"
}
end

defp release_for(:aarch64) do
%{
url:
"https://github.com/firecracker-microvm/firecracker/releases/download/" <>
"v#{@version}/firecracker-v#{@version}-aarch64.tgz",
sha256: "531c713cdbc37d4b8bc2533d851aabc0267096afa1768086a37672abb668efd7",
firecracker_path: "release-v#{@version}-aarch64/firecracker-v#{@version}-aarch64",
jailer_path: "release-v#{@version}-aarch64/jailer-v#{@version}-aarch64"
}
end

defp install!(
%{url: url, sha256: sha256, firecracker_path: fc_rel, jailer_path: jailer_rel},
prefix
) do
extract_dir = Path.join(prefix, ".firecracker-extract")

Mix.shell().info("Downloading Firecracker v#{@version} from #{url} ...")

case Redist.Targz.install(url, sha256, extract_dir) do
:ok -> :ok
{:error, reason} -> Mix.raise("Download from #{url} failed: #{inspect(reason)}")
end

dst_fc = Path.join(prefix, "firecracker")
dst_jailer = Path.join(prefix, "jailer")

# The release ships version-stamped names; copy to bare basenames so SafeBin
# validation passes. The helper matches on basename, not full path.
File.cp!(Path.join(extract_dir, fc_rel), dst_fc)
File.cp!(Path.join(extract_dir, jailer_rel), dst_jailer)
File.chmod!(dst_fc, 0o755)
File.chmod!(dst_jailer, 0o755)
_ = File.rm_rf!(extract_dir)

Mix.shell().info("Installed #{dst_fc}")
Mix.shell().info("Installed #{dst_jailer}")
end

defp print_config(prefix) do
fc = Path.join(prefix, "firecracker")
jailer = Path.join(prefix, "jailer")

# This task runs unprivileged, so the binaries land owned by the invoking
# user. The suidhelper's SafeBin refuses any binary not owned by root and not
# free of group/other write bits, so the operator MUST chown/chmod them or
# every jailer launch fails closed. Print the exact commands rather than a
# vague "ensure root-owned".
Mix.shell().info("""

Almost done. Run these as root so the setuid helper will accept the binaries
(it refuses any jailer/firecracker not owned by root):

sudo chown root:root #{fc} #{jailer}
sudo chmod 0755 #{fc} #{jailer}

Then add to /etc/hyper/config.toml (file: root-owned, mode 0644):

[tools]
firecracker = "#{fc}"
jailer = "#{jailer}"
""")
end
end
93 changes: 93 additions & 0 deletions lib/mix/tasks/suidhelper.install.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
defmodule Mix.Tasks.Suidhelper.Install do
@shortdoc "Build, stamp, and install the setuid helper"
@moduledoc """
Builds, stamps, and installs the Rust setuid helper.

mix suidhelper.install

Two steps:

1. `cargo xtask stamp` in `native/suidhelper` builds the release binary and
writes its BLAKE3 self-checksum into `.note.sum` (the same step the
`:suidhelper_stamp` compiler runs).
2. The stamped binary is copied setuid-root (mode `4755`) to
`/usr/local/bin/hyper-suidhelper`.

The copy needs root, but Mix runs every subprocess in its own session with no
controlling terminal (`erl_child_setup` calls `setsid`), so a nested `sudo`
cannot open `/dev/tty` to prompt for a password. This task therefore only runs
`sudo` itself when it is already non-interactive (`sudo -n` succeeds, e.g.
`NOPASSWD` or a usable cached credential). Otherwise it prints the exact
privileged command for you to run in your own terminal.

This is the privileged counterpart to `mix suidhelper.stamp`, which stamps
only. `cargo` and the helper's toolchain (see
`native/suidhelper/rust-toolchain.toml`) must be installed.
"""

use Mix.Task

@helper_dir "native/suidhelper"
@source Path.join(@helper_dir, "target/release/hyper-suidhelper")
# Must match `Hyper.Cfg.Tools.suidhelper/0`'s default path and the xtask's
# `INSTALL_PATH`: a `PATH` location the unprivileged node can exec.
@install_path "/usr/local/bin/hyper-suidhelper"

@impl Mix.Task
def run(argv) do
stamp!(argv)
install_privileged()
end

defp stamp!(argv) do
case System.cmd("cargo", ["xtask", "stamp" | argv],
cd: @helper_dir,
into: IO.stream(:stdio, :line)
) do
{_, 0} ->
:ok

{_, _} ->
Mix.raise("""
`cargo xtask stamp` failed building the suidhelper.

Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml)
are installed.
""")
end
end

defp install_privileged do
if passwordless_sudo?() do
Mix.shell().info("Installing #{@source} -> #{@install_path} (setuid root)")

case System.cmd("sudo", install_argv(), into: IO.stream(:stdio, :line)) do
{_, 0} -> Mix.shell().info("installed #{@install_path} (setuid root)")
{_, _} -> Mix.raise(manual_instructions())
end
else
Mix.shell().info(manual_instructions())
end
end

# `sudo -n true` exits 0 only when sudo can run without prompting. With no
# controlling terminal a cached `tty_tickets` credential is invisible, so this
# is true essentially only under `NOPASSWD` -- exactly the case where the
# nested `sudo install` below can succeed.
defp passwordless_sudo? do
match?({_, 0}, System.cmd("sudo", ["-n", "true"], stderr_to_stdout: true))
end

defp install_argv,
do: ["install", "-o", "root", "-g", "root", "-m", "4755", @source, @install_path]

defp manual_instructions do
"""

The binary is built and stamped, but installing it setuid-root needs a
password and `sudo` has no terminal to prompt on here. Run the copy yourself:

sudo #{Enum.join(install_argv(), " ")}
"""
end
end
4 changes: 4 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ defmodule Hyper.MixProject do
# Cache the PLTs in a stable, gitignored dir so CI can cache them.
plt_local_path: "priv/plts",
plt_core_path: "priv/plts",
# `:mix` is needed so the Mix tasks under `lib/mix/tasks` (which call
# `Mix.raise/1`, `Mix.shell/0`, and implement the `Mix.Task` behaviour)
# resolve instead of tripping `unknown_function`.
plt_add_apps: [:mix],
# Verify @specs against actual returns, and flag ignored return values.
flags: [:unmatched_returns, :extra_return, :missing_return]
]
Expand Down
Loading