Skip to content

Commit e2253f6

Browse files
committed
fix(suidhelper): make mix suidhelper.install tty-safe
Mix spawns subprocesses in their own session with no controlling terminal (erl_child_setup calls setsid), so the nested `sudo install` could never prompt for a password and always failed with "a terminal is required". Split the task: `cargo xtask stamp` builds + stamps unprivileged, then the privileged copy runs via sudo only when it is already non-interactive (`sudo -n` succeeds); otherwise it prints the exact command to run by hand.
1 parent b68dd5e commit e2253f6

1 file changed

Lines changed: 77 additions & 24 deletions

File tree

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,93 @@
11
defmodule Mix.Tasks.Suidhelper.Install do
2-
@shortdoc "Build, stamp, and install the setuid helper (wraps `cargo xtask install`)"
2+
@shortdoc "Build, stamp, and install the setuid helper"
33
@moduledoc """
4-
Builds, stamps, and installs the Rust setuid helper by wrapping
5-
`cargo xtask install` in `native/suidhelper`.
4+
Builds, stamps, and installs the Rust setuid helper.
65
76
mix suidhelper.install
87
9-
The xtask first stamps the release binary (BLAKE3 self-checksum into
10-
`.note.sum`, the same step the `:suidhelper_stamp` compiler runs) and then
11-
installs it setuid-root to `/usr/local/bin/hyper-suidhelper` via `sudo
12-
install`. `sudo` may prompt for a password on the controlling terminal.
8+
Two steps:
139
14-
This is the privileged counterpart to `mix suidhelper.stamp`: that one only
15-
rebuilds, stamps, and re-captures the embedded build identity; this one also
16-
places the binary on `PATH` setuid-root. `cargo` and the helper's toolchain
17-
(see `native/suidhelper/rust-toolchain.toml`) must be installed.
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.
1826
"""
1927

2028
use Mix.Task
2129

2230
@helper_dir "native/suidhelper"
31+
@source Path.join(@helper_dir, "target/release/hyper-suidhelper")
32+
# Must match `Hyper.Config`'s default `suid_helper` path and the xtask's
33+
# `INSTALL_PATH`: a `PATH` location the unprivileged node can exec.
34+
@install_path "/usr/local/bin/hyper-suidhelper"
2335

2436
@impl Mix.Task
2537
def run(argv) do
26-
{_, 0} =
27-
System.cmd("cargo", ["xtask", "install" | argv],
28-
cd: @helper_dir,
29-
into: IO.stream(:stdio, :line)
30-
)
31-
rescue
32-
MatchError ->
33-
Mix.raise("""
34-
`cargo xtask install` failed installing the suidhelper.
35-
36-
Ensure `cargo` and the helper's toolchain (see #{@helper_dir}/rust-toolchain.toml)
37-
are installed, and that `sudo` is available for the setuid install step.
38-
""")
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+
"""
3992
end
4093
end

0 commit comments

Comments
 (0)