Skip to content

Commit 4ddcd4d

Browse files
committed
feat(suidhelper): source device binaries from config, drop caller --bin
The unprivileged node used to pass `--bin <path>` to the setuid helper for every losetup/dmsetup/blockdev op. Letting the caller name the binary the helper escalates to run is a needless trust hole, even with SafeBin checks. Move the paths into the helper-owned config (/etc/hyper/config.toml) with sane defaults (/usr/sbin/{losetup,dmsetup,blockdev}); the helper validates each as a SafeBin (absolute, root-owned, non-writable, exact basename) at dispatch, as the real uid, before acquiring root. The `--bin` argument is gone from both sides. Also: - Add a `dmsetup targets` op so the dm-target readiness probe runs through the helper (it opens /dev/mapper/control, which needs root) instead of shelling dmsetup directly as the BEAM user. - An absent config file now falls back to the built-in defaults (trusted, compiled into the root-owned binary); a present-but-untrusted file stays fatal. Drop the Elixir-side *_path config and per-tool presence checks. - Add :mix to the dialyzer PLT so the Mix tasks resolve Mix.raise/shell.
1 parent e2253f6 commit 4ddcd4d

13 files changed

Lines changed: 265 additions & 179 deletions

File tree

lib/hyper/config.ex

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ defmodule Hyper.Config do
1616
@parent_cgroup Application.compile_env(:hyper, :cgroup_parent, "hyper")
1717
@uid_gid_range Application.compile_env!(:hyper, :uid_gid_range)
1818
@layer_dir Application.compile_env!(:hyper, :layer_dir)
19-
@losetup_path Application.compile_env(:hyper, :losetup_path, "losetup")
20-
@dmsetup_path Application.compile_env(:hyper, :dmsetup_path, "dmsetup")
21-
@blockdev_path Application.compile_env(:hyper, :blockdev_path, "blockdev")
2219
@skopeo_path Application.compile_env(:hyper, :skopeo_path, "skopeo")
2320
@umoci_path Application.compile_env(:hyper, :umoci_path, nil)
2421
@mke2fs_path Application.compile_env(:hyper, :mke2fs_path, "mke2fs")
@@ -111,15 +108,6 @@ defmodule Hyper.Config do
111108
@spec layer_dir :: Path.t()
112109
def layer_dir, do: @layer_dir
113110

114-
@doc "Path to the losetup binary."
115-
def losetup_path, do: @losetup_path
116-
117-
@doc "Path to the dmsetup binary."
118-
def dmsetup_path, do: @dmsetup_path
119-
120-
@doc "Path to the blockdev binary."
121-
def blockdev_path, do: @blockdev_path
122-
123111
@doc "Path to the skopeo binary (used by `Hyper.Img.OciLoader` to pull OCI images)."
124112
def skopeo_path, do: @skopeo_path
125113

@@ -132,15 +120,20 @@ defmodule Hyper.Config do
132120
@doc "Path to the mke2fs binary (used by `Hyper.Img.OciLoader` to build the ext4 rootfs)."
133121
def mke2fs_path, do: @mke2fs_path
134122

123+
# Where `cargo xtask install` (via `mix suidhelper.install`) drops the helper.
124+
@default_suid_helper "/usr/local/bin/hyper-suidhelper"
125+
135126
@doc """
136-
Path to the setuid-root device helper (`hyper-suidhelper`). Required: the node
137-
runs unprivileged and routes every `losetup`/`dmsetup`/`blockdev` operation
138-
through it.
127+
Path to the setuid-root device helper (`hyper-suidhelper`). The node runs
128+
unprivileged and routes every `losetup`/`dmsetup`/`blockdev` operation through
129+
it.
139130
140-
Runtime config (host-specific), so it can be set per node without recompiling.
131+
Defaults to `#{@default_suid_helper}`, the install path used by `mix
132+
suidhelper.install`. Runtime config (host-specific), so an operator who
133+
installs it elsewhere can override per node without recompiling.
141134
"""
142135
@spec suid_helper :: Path.t()
143-
def suid_helper, do: Application.fetch_env!(:hyper, :suid_helper)
136+
def suid_helper, do: Application.get_env(:hyper, :suid_helper, @default_suid_helper)
144137

145138
@doc """
146139
Directory for per-VM scratch (writable-layer COW) files. Must be node-local and

lib/hyper/suid_helper.ex

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ defmodule Hyper.SuidHelper do
1616
self-test and reports the base path it was compiled against.
1717
"""
1818

19-
alias Hyper.SuidHelper.{Blockdev, Dmsetup, Expected, Losetup}
19+
alias Hyper.SuidHelper.{Dmsetup, Expected}
2020

2121
use OpenTelemetryDecorator
2222

@@ -51,18 +51,20 @@ defmodule Hyper.SuidHelper do
5151
end
5252

5353
@doc """
54-
Check that the setuid helper and every tool it execs are usable on this
55-
machine: the helper binary is present, is the build this release expects
56-
(`verify_version/0`), then each tool submodule's own check.
54+
Check that the setuid helper is usable on this machine: the helper binary is
55+
present, is the build this release expects (`verify_version/0`), and the kernel
56+
exposes the device-mapper targets we need (`Dmsetup.test_system/0`, which also
57+
exercises the helper's configured `dmsetup` binary).
58+
59+
The `losetup`/`blockdev` binaries are validated by the helper the first time
60+
each is used; their paths live in the helper's own config, not here.
5761
"""
5862
@spec test_system() :: :ok | {:error, term()}
5963
@decorate with_span("Hyper.SuidHelper.test_system")
6064
def test_system do
6165
with :ok <- helper_present(),
62-
:ok <- verify_version(),
63-
:ok <- Losetup.test_system(),
64-
:ok <- Dmsetup.test_system() do
65-
Blockdev.test_system()
66+
:ok <- verify_version() do
67+
Dmsetup.test_system()
6668
end
6769
end
6870

lib/hyper/suid_helper/blockdev.ex

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,9 @@ defmodule Hyper.SuidHelper.Blockdev do
1111
@spec device_sectors(Path.t()) :: {:ok, pos_integer()} | {:error, err()}
1212
@decorate with_span("Hyper.SuidHelper.Blockdev.device_sectors", include: [:path])
1313
def device_sectors(path) do
14-
case SuidHelper.exec(["blockdev", "--bin", Hyper.Config.blockdev_path(), "--getsz", path]) do
14+
case SuidHelper.exec(["blockdev", "--getsz", path]) do
1515
{:ok, %{"sectors" => n}} -> {:ok, n}
1616
{:error, _} = err -> err
1717
end
1818
end
19-
20-
@doc "Check the blockdev binary is present."
21-
@spec test_system() :: :ok | {:error, :blockdev_not_found}
22-
@decorate with_span("Hyper.SuidHelper.Blockdev.test_system")
23-
def test_system do
24-
if System.find_executable(Hyper.Config.blockdev_path()),
25-
do: :ok,
26-
else: {:error, :blockdev_not_found}
27-
end
2819
end

lib/hyper/suid_helper/dmsetup.ex

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,7 @@ defmodule Hyper.SuidHelper.Dmsetup do
5959
@spec remove(String.t()) :: :ok | {:error, err()}
6060
@decorate with_span("Hyper.SuidHelper.Dmsetup.remove", include: [:name])
6161
def remove(name) do
62-
case SuidHelper.exec([
63-
"dmsetup",
64-
"--bin",
65-
Hyper.Config.dmsetup_path(),
66-
"remove",
67-
"--retry",
68-
name
69-
]) do
62+
case SuidHelper.exec(["dmsetup", "remove", "--retry", name]) do
7063
{:ok, _} -> :ok
7164
{:error, _} = err -> err
7265
end
@@ -76,39 +69,31 @@ defmodule Hyper.SuidHelper.Dmsetup do
7669
@spec message(String.t(), String.t()) :: :ok | {:error, err()}
7770
@decorate with_span("Hyper.SuidHelper.Dmsetup.message", include: [:name, :message])
7871
def message(name, message) do
79-
argv =
80-
["dmsetup", "--bin", Hyper.Config.dmsetup_path(), "message", name, "--message", message]
81-
82-
case SuidHelper.exec(argv) do
72+
case SuidHelper.exec(["dmsetup", "message", name, "--message", message]) do
8373
{:ok, _} -> :ok
8474
{:error, _} = err -> err
8575
end
8676
end
8777

8878
@doc """
89-
Check the dmsetup binary is present and the kernel exposes the dm targets we
90-
use (snapshot, thin, thin-pool).
79+
Verify the kernel exposes the dm targets we use (snapshot, thin, thin-pool).
80+
81+
Routes through the setuid helper: `dmsetup targets` opens `/dev/mapper/control`,
82+
which needs root, and the BEAM runs unprivileged. The helper validates its
83+
configured `dmsetup` binary before running it, so a missing or unsafe binary
84+
surfaces here too.
9185
"""
9286
@spec test_system() :: :ok | {:error, term()}
9387
@decorate with_span("Hyper.SuidHelper.Dmsetup.test_system")
9488
def test_system do
95-
if System.find_executable(Hyper.Config.dmsetup_path()),
96-
do: test_targets(),
97-
else: {:error, :dmsetup_not_found}
98-
end
99-
100-
@doc "Verify the kernel exposes the dm targets we use (snapshot, thin, thin-pool)."
101-
@spec test_targets() :: :ok | {:error, term()}
102-
@decorate with_span("Hyper.SuidHelper.Dmsetup.test_targets")
103-
def test_targets do
104-
case System.cmd(Hyper.Config.dmsetup_path(), ["targets"], stderr_to_stdout: true) do
105-
{out, 0} ->
89+
case SuidHelper.exec(["dmsetup", "targets"]) do
90+
{:ok, %{"output" => out}} ->
10691
have = parse_targets(out)
10792
missing = Enum.reject(@required_targets, &MapSet.member?(have, &1))
10893
if missing == [], do: :ok, else: {:error, {:missing_dm_targets, missing}}
10994

110-
{out, code} ->
111-
{:error, {:dmsetup_targets_failed, code, String.trim(out)}}
95+
{:error, {code, msg}} ->
96+
{:error, {:dmsetup_targets_failed, code, msg}}
11297
end
11398
end
11499

@@ -145,9 +130,7 @@ defmodule Hyper.SuidHelper.Dmsetup do
145130
# create flags (e.g. `--readonly`). Returns the `/dev/mapper/<name>` path.
146131
@spec create(String.t(), String.t(), [String.t()]) :: {:ok, Path.t()} | {:error, err()}
147132
defp create(name, table, flags) do
148-
argv =
149-
["dmsetup", "--bin", Hyper.Config.dmsetup_path(), "create", name] ++
150-
flags ++ ["--table", table]
133+
argv = ["dmsetup", "create", name] ++ flags ++ ["--table", table]
151134

152135
case SuidHelper.exec(argv) do
153136
{:ok, %{"device" => dev}} -> {:ok, dev}

lib/hyper/suid_helper/losetup.ex

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule Hyper.SuidHelper.Losetup do
1111
@spec attach_ro(Path.t()) :: {:ok, Path.t()} | {:error, err()}
1212
@decorate with_span("Hyper.SuidHelper.Losetup.attach_ro", include: [:path])
1313
def attach_ro(path) do
14-
case SuidHelper.exec(["losetup", "--bin", Hyper.Config.losetup_path(), "attach", path]) do
14+
case SuidHelper.exec(["losetup", "attach", path]) do
1515
{:ok, %{"device" => dev}} -> {:ok, dev}
1616
{:error, _} = err -> err
1717
end
@@ -21,14 +21,7 @@ defmodule Hyper.SuidHelper.Losetup do
2121
@spec attach_rw(Path.t()) :: {:ok, Path.t()} | {:error, err()}
2222
@decorate with_span("Hyper.SuidHelper.Losetup.attach_rw", include: [:path])
2323
def attach_rw(path) do
24-
case SuidHelper.exec([
25-
"losetup",
26-
"--bin",
27-
Hyper.Config.losetup_path(),
28-
"attach",
29-
"--rw",
30-
path
31-
]) do
24+
case SuidHelper.exec(["losetup", "attach", "--rw", path]) do
3225
{:ok, %{"device" => dev}} -> {:ok, dev}
3326
{:error, _} = err -> err
3427
end
@@ -38,18 +31,9 @@ defmodule Hyper.SuidHelper.Losetup do
3831
@spec detach(Path.t()) :: :ok | {:error, err()}
3932
@decorate with_span("Hyper.SuidHelper.Losetup.detach", include: [:dev])
4033
def detach(dev) do
41-
case SuidHelper.exec(["losetup", "--bin", Hyper.Config.losetup_path(), "detach", dev]) do
34+
case SuidHelper.exec(["losetup", "detach", dev]) do
4235
{:ok, _} -> :ok
4336
{:error, _} = err -> err
4437
end
4538
end
46-
47-
@doc "Check the losetup binary is present."
48-
@spec test_system() :: :ok | {:error, :losetup_not_found}
49-
@decorate with_span("Hyper.SuidHelper.Losetup.test_system")
50-
def test_system do
51-
if System.find_executable(Hyper.Config.losetup_path()),
52-
do: :ok,
53-
else: {:error, :losetup_not_found}
54-
end
5539
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
]

native/suidhelper/src/config.rs

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-License-Identifier: AGPL-3.0-only
22
//! Runtime host configuration, read from a single root-owned TOML file.
33
4+
use crate::util::safe_bin::{self, SafeBin};
45
use crate::util::safe_file::{self, IsRegularFile, OnlyRootWritable, RootOwner, SafeFile};
56
use crate::util::safe_path::{self, IsAbsolute, SafePath, StrictComponents};
67
use nix::fcntl::OFlag;
@@ -41,16 +42,57 @@ fn config_path() -> PathBuf {
4142
}
4243

4344
/// Hyper's /etc/hyper/config.toml file format.
45+
///
46+
/// The device-tool paths are read from here (never from the unprivileged
47+
/// caller, which is why there is no `--bin` argument): the helper alone decides
48+
/// which binary it escalates to run. Each defaults to its usual location and is
49+
/// validated as a [`SafeBin`] before use.
4450
#[derive(Debug, Clone, Deserialize)]
4551
pub struct Config {
4652
work_dir: PathBuf,
53+
#[serde(default = "default_dmsetup")]
54+
dmsetup: PathBuf,
55+
#[serde(default = "default_losetup")]
56+
losetup: PathBuf,
57+
#[serde(default = "default_blockdev")]
58+
blockdev: PathBuf,
59+
}
60+
61+
// The default data root. Must match the Elixir node's `@dev_work_dir`, which it
62+
// uses when the same config file is absent, so both sides agree (see
63+
// `Hyper.Node.check_helper_base`).
64+
fn default_work_dir() -> PathBuf {
65+
PathBuf::from("/srv/hyper")
66+
}
67+
68+
fn default_dmsetup() -> PathBuf {
69+
PathBuf::from("/usr/sbin/dmsetup")
70+
}
71+
72+
fn default_losetup() -> PathBuf {
73+
PathBuf::from("/usr/sbin/losetup")
74+
}
75+
76+
fn default_blockdev() -> PathBuf {
77+
PathBuf::from("/usr/sbin/blockdev")
78+
}
79+
80+
impl Default for Config {
81+
fn default() -> Self {
82+
Self {
83+
work_dir: default_work_dir(),
84+
dmsetup: default_dmsetup(),
85+
losetup: default_losetup(),
86+
blockdev: default_blockdev(),
87+
}
88+
}
4789
}
4890

4991
impl Config {
5092
/// The process-wide config, loaded once (and forced unprivileged by
51-
/// [`Config::init`]). A load failure is fatal: the helper cannot safely
52-
/// operate without a trusted data root, so it prints the error and exits
53-
/// rather than guessing a default.
93+
/// [`Config::init`]). An absent file yields the built-in defaults; a
94+
/// *present but untrusted* file (wrong owner/mode, malformed) is fatal -
95+
/// the helper prints the error and exits rather than trusting it.
5496
pub fn get() -> &'static Config {
5597
LazyLock::force(&CONFIG)
5698
}
@@ -74,6 +116,21 @@ impl Config {
74116
self.work_dir.join("jails")
75117
}
76118

119+
/// The validated `dmsetup` binary the helper will run.
120+
pub fn dmsetup(&self) -> Result<SafeBin<"dmsetup">, safe_bin::Error> {
121+
SafeBin::from_path(&self.dmsetup)
122+
}
123+
124+
/// The validated `losetup` binary the helper will run.
125+
pub fn losetup(&self) -> Result<SafeBin<"losetup">, safe_bin::Error> {
126+
SafeBin::from_path(&self.losetup)
127+
}
128+
129+
/// The validated `blockdev` binary the helper will run.
130+
pub fn blockdev(&self) -> Result<SafeBin<"blockdev">, safe_bin::Error> {
131+
SafeBin::from_path(&self.blockdev)
132+
}
133+
77134
/// Read, ownership-check, parse, and validate the config file. See the module
78135
/// docs for the trust model.
79136
pub fn safe_load() -> Result<Self, LoadingError> {
@@ -82,7 +139,18 @@ impl Config {
82139
let safe_path: SafePath<IsAbsolute, StrictComponents> = path.clone().try_into()?;
83140

84141
let file: SafeFile<IsRegularFile, RootOwner, OnlyRootWritable> =
85-
SafeFile::open(&safe_path, OFlag::O_RDONLY)?;
142+
match SafeFile::open(&safe_path, OFlag::O_RDONLY) {
143+
Ok(file) => file,
144+
// A genuinely-absent file means "use the built-in defaults": those
145+
// are compiled into this root-owned binary, so they are trusted. Any
146+
// OTHER failure - a present but wrong-owner/mode file, an I/O error -
147+
// stays fatal, because it is a signal (someone put an untrusted file
148+
// there), not an absence.
149+
Err(safe_file::ValidationError::Open(nix::errno::Errno::ENOENT)) => {
150+
return Ok(Self::default())
151+
}
152+
Err(e) => return Err(e.into()),
153+
};
86154

87155
let body = std::io::read_to_string(std::fs::File::from(file.into_owned_fd()))
88156
.map_err(|_| LoadingError::Unreadable(path.clone()))?;

native/suidhelper/src/tools/dmsetup/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ enum DmOp {
5252
#[arg(long)]
5353
message: ThinMessage,
5454
},
55+
/// List the target types the kernel device-mapper exposes. Read-only, but
56+
/// still needs root: it opens `/dev/mapper/control`.
57+
Targets,
5558
}
5659

5760
#[derive(Serialize)]
@@ -60,6 +63,7 @@ pub enum DmsetupOut {
6063
Created { device: PathBuf },
6164
Removed,
6265
Messaged,
66+
Targets { output: String },
6367
}
6468

6569
pub struct Dmsetup {
@@ -105,6 +109,9 @@ impl IsTool for Dmsetup {
105109
.arg("0")
106110
.arg(message.to_string());
107111
}
112+
DmOp::Targets => {
113+
cmd.arg("targets");
114+
}
108115
}
109116

110117
cmd.env_clear().output()
@@ -124,6 +131,9 @@ impl IsTool for Dmsetup {
124131
},
125132
DmOp::Remove { .. } => DmsetupOut::Removed,
126133
DmOp::Message { .. } => DmsetupOut::Messaged,
134+
DmOp::Targets => DmsetupOut::Targets {
135+
output: String::from_utf8_lossy(&out.stdout).into_owned(),
136+
},
127137
})
128138
}
129139
}

0 commit comments

Comments
 (0)