Skip to content

Commit 47dec42

Browse files
chore(dev): mix firecracker.install (#34)
1 parent 9ee5de4 commit 47dec42

3 files changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
defmodule Mix.Tasks.Firecracker.Install do
2+
@shortdoc "Download, verify, and install the pinned Firecracker release"
3+
@moduledoc """
4+
Downloads, verifies, and installs the pinned Firecracker release (v1.16.0)
5+
for the current CPU architecture.
6+
7+
mix firecracker.install [--prefix DIR]
8+
9+
Steps performed:
10+
11+
1. Detects the CPU architecture (`x86_64` or `aarch64`).
12+
2. Downloads the release tarball and verifies its SHA-256 checksum.
13+
3. Extracts the tarball, then copies the binaries to `<prefix>/firecracker`
14+
and `<prefix>/jailer` using the **bare basenames** `firecracker` and
15+
`jailer`. The setuid helper validates binaries via `SafeBin<"firecracker">`
16+
and `SafeBin<"jailer">`, which match on basename only — version-stamped
17+
names such as `firecracker-v1.16.0-x86_64` are rejected unconditionally.
18+
4. Marks both binaries executable (`0o755`).
19+
5. Prints the `/etc/hyper/config.toml` snippet the operator needs to paste.
20+
21+
This task installs **unprivileged** binaries and prints configuration.
22+
Privilege at runtime is handled by `hyper-suidhelper` (the setuid helper).
23+
This task does **not** setuid `firecracker` or `jailer`. Install and setuid
24+
the helper separately with `mix suidhelper.install`.
25+
26+
## Options
27+
28+
* `--prefix DIR` — installation directory (default: `/opt/firecracker`).
29+
30+
## Security requirements
31+
32+
After installing, ensure:
33+
34+
* The binaries are root-owned and **not** group- or world-writable.
35+
The suidhelper refuses binaries with loose permissions.
36+
* `/etc/hyper/config.toml` is root-owned with mode `0644`.
37+
"""
38+
39+
use Mix.Task
40+
41+
@version "1.16.0"
42+
@default_prefix "/opt/firecracker"
43+
44+
@impl Mix.Task
45+
@spec run([String.t()]) :: :ok
46+
def run(argv) do
47+
{opts, _rest, _invalid} = OptionParser.parse(argv, strict: [prefix: :string])
48+
prefix = Keyword.get(opts, :prefix, @default_prefix)
49+
50+
arch = detect_arch!()
51+
52+
case Application.ensure_all_started(:req) do
53+
{:ok, _} -> :ok
54+
{:error, {reason, app}} -> Mix.raise("Cannot start HTTP client #{app}: #{inspect(reason)}")
55+
end
56+
57+
install!(release_for(arch), prefix)
58+
print_config(prefix)
59+
end
60+
61+
defp detect_arch! do
62+
case Sys.Arch.current() do
63+
{:ok, arch} ->
64+
arch
65+
66+
{:error, {:unsupported_arch, raw}} ->
67+
Mix.raise(
68+
"Unsupported CPU architecture #{inspect(raw)}; " <>
69+
"Firecracker supports x86_64 and aarch64."
70+
)
71+
end
72+
end
73+
74+
defp release_for(:x86_64) do
75+
%{
76+
url:
77+
"https://github.com/firecracker-microvm/firecracker/releases/download/" <>
78+
"v#{@version}/firecracker-v#{@version}-x86_64.tgz",
79+
sha256: "bd04e26952d4e158085778c6230a0b383d2619c319182e27eaa9d61a212e92d6",
80+
firecracker_path: "release-v#{@version}-x86_64/firecracker-v#{@version}-x86_64",
81+
jailer_path: "release-v#{@version}-x86_64/jailer-v#{@version}-x86_64"
82+
}
83+
end
84+
85+
defp release_for(:aarch64) do
86+
%{
87+
url:
88+
"https://github.com/firecracker-microvm/firecracker/releases/download/" <>
89+
"v#{@version}/firecracker-v#{@version}-aarch64.tgz",
90+
sha256: "531c713cdbc37d4b8bc2533d851aabc0267096afa1768086a37672abb668efd7",
91+
firecracker_path: "release-v#{@version}-aarch64/firecracker-v#{@version}-aarch64",
92+
jailer_path: "release-v#{@version}-aarch64/jailer-v#{@version}-aarch64"
93+
}
94+
end
95+
96+
defp install!(
97+
%{url: url, sha256: sha256, firecracker_path: fc_rel, jailer_path: jailer_rel},
98+
prefix
99+
) do
100+
extract_dir = Path.join(prefix, ".firecracker-extract")
101+
102+
Mix.shell().info("Downloading Firecracker v#{@version} from #{url} ...")
103+
104+
case Redist.Targz.install(url, sha256, extract_dir) do
105+
:ok -> :ok
106+
{:error, reason} -> Mix.raise("Download from #{url} failed: #{inspect(reason)}")
107+
end
108+
109+
dst_fc = Path.join(prefix, "firecracker")
110+
dst_jailer = Path.join(prefix, "jailer")
111+
112+
# The release ships version-stamped names; copy to bare basenames so SafeBin
113+
# validation passes. The helper matches on basename, not full path.
114+
File.cp!(Path.join(extract_dir, fc_rel), dst_fc)
115+
File.cp!(Path.join(extract_dir, jailer_rel), dst_jailer)
116+
File.chmod!(dst_fc, 0o755)
117+
File.chmod!(dst_jailer, 0o755)
118+
_ = File.rm_rf!(extract_dir)
119+
120+
Mix.shell().info("Installed #{dst_fc}")
121+
Mix.shell().info("Installed #{dst_jailer}")
122+
end
123+
124+
defp print_config(prefix) do
125+
fc = Path.join(prefix, "firecracker")
126+
jailer = Path.join(prefix, "jailer")
127+
128+
# This task runs unprivileged, so the binaries land owned by the invoking
129+
# user. The suidhelper's SafeBin refuses any binary not owned by root and not
130+
# free of group/other write bits, so the operator MUST chown/chmod them or
131+
# every jailer launch fails closed. Print the exact commands rather than a
132+
# vague "ensure root-owned".
133+
Mix.shell().info("""
134+
135+
Almost done. Run these as root so the setuid helper will accept the binaries
136+
(it refuses any jailer/firecracker not owned by root):
137+
138+
sudo chown root:root #{fc} #{jailer}
139+
sudo chmod 0755 #{fc} #{jailer}
140+
141+
Then add to /etc/hyper/config.toml (file: root-owned, mode 0644):
142+
143+
[tools]
144+
firecracker = "#{fc}"
145+
jailer = "#{jailer}"
146+
""")
147+
end
148+
end
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
defmodule Mix.Tasks.Suidhelper.Install do
2+
@shortdoc "Build, stamp, and install the setuid helper"
3+
@moduledoc """
4+
Builds, stamps, and installs the Rust setuid helper.
5+
6+
mix suidhelper.install
7+
8+
Two steps:
9+
10+
1. `cargo xtask stamp` in `native/suidhelper` builds the release binary and
11+
writes its BLAKE3 self-checksum into `.note.sum` (the same step the
12+
`:suidhelper_stamp` compiler runs).
13+
2. The stamped binary is copied setuid-root (mode `4755`) to
14+
`/usr/local/bin/hyper-suidhelper`.
15+
16+
The copy needs root, but Mix runs every subprocess in its own session with no
17+
controlling terminal (`erl_child_setup` calls `setsid`), so a nested `sudo`
18+
cannot open `/dev/tty` to prompt for a password. This task therefore only runs
19+
`sudo` itself when it is already non-interactive (`sudo -n` succeeds, e.g.
20+
`NOPASSWD` or a usable cached credential). Otherwise it prints the exact
21+
privileged command for you to run in your own terminal.
22+
23+
This is the privileged counterpart to `mix suidhelper.stamp`, which stamps
24+
only. `cargo` and the helper's toolchain (see
25+
`native/suidhelper/rust-toolchain.toml`) must be installed.
26+
"""
27+
28+
use Mix.Task
29+
30+
@helper_dir "native/suidhelper"
31+
@source Path.join(@helper_dir, "target/release/hyper-suidhelper")
32+
# Must match `Hyper.Cfg.Tools.suidhelper/0`'s default path and the xtask's
33+
# `INSTALL_PATH`: a `PATH` location the unprivileged node can exec.
34+
@install_path "/usr/local/bin/hyper-suidhelper"
35+
36+
@impl Mix.Task
37+
def run(argv) do
38+
stamp!(argv)
39+
install_privileged()
40+
end
41+
42+
defp stamp!(argv) do
43+
case System.cmd("cargo", ["xtask", "stamp" | argv],
44+
cd: @helper_dir,
45+
into: IO.stream(:stdio, :line)
46+
) do
47+
{_, 0} ->
48+
:ok
49+
50+
{_, _} ->
51+
Mix.raise("""
52+
`cargo xtask stamp` failed building the suidhelper.
53+
54+
Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml)
55+
are installed.
56+
""")
57+
end
58+
end
59+
60+
defp install_privileged do
61+
if passwordless_sudo?() do
62+
Mix.shell().info("Installing #{@source} -> #{@install_path} (setuid root)")
63+
64+
case System.cmd("sudo", install_argv(), into: IO.stream(:stdio, :line)) do
65+
{_, 0} -> Mix.shell().info("installed #{@install_path} (setuid root)")
66+
{_, _} -> Mix.raise(manual_instructions())
67+
end
68+
else
69+
Mix.shell().info(manual_instructions())
70+
end
71+
end
72+
73+
# `sudo -n true` exits 0 only when sudo can run without prompting. With no
74+
# controlling terminal a cached `tty_tickets` credential is invisible, so this
75+
# is true essentially only under `NOPASSWD` -- exactly the case where the
76+
# nested `sudo install` below can succeed.
77+
defp passwordless_sudo? do
78+
match?({_, 0}, System.cmd("sudo", ["-n", "true"], stderr_to_stdout: true))
79+
end
80+
81+
defp install_argv,
82+
do: ["install", "-o", "root", "-g", "root", "-m", "4755", @source, @install_path]
83+
84+
defp manual_instructions do
85+
"""
86+
87+
The binary is built and stamped, but installing it setuid-root needs a
88+
password and `sudo` has no terminal to prompt on here. Run the copy yourself:
89+
90+
sudo #{Enum.join(install_argv(), " ")}
91+
"""
92+
end
93+
end

mix.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ defmodule Hyper.MixProject do
2424
# Cache the PLTs in a stable, gitignored dir so CI can cache them.
2525
plt_local_path: "priv/plts",
2626
plt_core_path: "priv/plts",
27+
# `:mix` is needed so the Mix tasks under `lib/mix/tasks` (which call
28+
# `Mix.raise/1`, `Mix.shell/0`, and implement the `Mix.Task` behaviour)
29+
# resolve instead of tripping `unknown_function`.
30+
plt_add_apps: [:mix],
2731
# Verify @specs against actual returns, and flag ignored return values.
2832
flags: [:unmatched_returns, :extra_return, :missing_return]
2933
]

0 commit comments

Comments
 (0)