nixos-rebuild-ng: add --elevate=run0

Locally this just prefixes activation commands with `run0 --`, so the
user's normal polkit agent (graphical or pkttyagent) handles auth.

For --target-host, run0 would need a controlling terminal, which we
deliberately do not allocate over SSH. Instead the equivalent
`systemd-run --uid=0 --pipe --wait ...` form is used. Without
--ask-elevate-password this relies on a polkit rule on the target
granting the deploying user `org.freedesktop.systemd1.manage-units`.
With --ask-elevate-password the command is wrapped in
polkit-stdin-agent, which registers a per-process polkit agent for the
child and answers the PAM conversation from stdin.

A transient unit does not inherit the SSH login environment. On NixOS
the unit's PATH is just the systemd store path, so neither `env` nor
`nix-env` would be found inside it. Elevator.wrap_remote() therefore
now takes the env mapping and the command, and returns the full remote
argv: sudo keeps the existing `/bin/sh -c 'exec /usr/bin/env -i ...'`
wrapper inside itself, while run0 puts the shell wrapper *around*
systemd-run and forwards the resolved values into the unit via
--setenv. The Arg/Args/EnvValue/PRESERVE_ENV types and the env-shell
helper move from process.py into elevate.py to avoid a circular import,
and callers are updated to import them from there.

polkit-stdin-agent is resolved on the machine doing the elevation
rather than baked in as a host-arch store path, which would be wrong
for --no-reexec, cross-arch deploys (re-exec hits ENOEXEC and falls
back), --rollback/--store-path, and Darwin deployers. Locally that is
PATH. Remotely, Elevator.for_target_config() binds the elevator to the
toplevel just placed on the target, and a small /bin/sh picker tries
<toplevel>/sw/bin/polkit-stdin-agent (target-arch, in the copied
closure) then PATH, exiting with an actionable error pointing at
system.tools.nixos-rebuild.enableRun0Elevation when neither resolves.
That option (added here) puts the agent in environment.systemPackages
and asserts security.polkit.enable.

A nixos-rebuild-target-host subtest exercises the full remote run0
path (polkit-stdin-agent + systemd-run + activation) end-to-end.

Drop the now-unused stdenv argument from package.nix while here.

Closes #507054.
This commit is contained in:
r-vdp
2026-04-16 20:32:13 +02:00
parent 4d4952ae28
commit c30b2c06d9
11 changed files with 533 additions and 121 deletions

View File

@@ -314,6 +314,27 @@ in
name = "nixos-rebuild";
package = config.system.build.nixos-rebuild;
})
(
{ config, ... }:
{
options.system.tools.nixos-rebuild.enableRun0Elevation = lib.mkEnableOption ''
support for being targeted by `nixos-rebuild --elevate=run0
--ask-elevate-password`.
This enables polkit and adds {command}`polkit-stdin-agent` to
{option}`environment.systemPackages` so that a deploying host
can find a target-architecture agent at
{file}`<toplevel>/sw/bin/polkit-stdin-agent` after copying the
closure (which is required for cross-architecture deploys and
mismatched nixpkgs revisions to work).
'';
config = lib.mkIf config.system.tools.nixos-rebuild.enableRun0Elevation {
security.polkit.enable = lib.mkDefault true;
environment.systemPackages = [ pkgs.polkit-stdin-agent ];
};
}
)
(mkToolModule {
name = "nixos-version";
package = nixos-version;

View File

@@ -22,6 +22,8 @@
};
system.includeBuildDependencies = true;
# Needed so the offline build of the target config succeeds.
system.extraDependencies = [ pkgs.polkit-stdin-agent ];
virtualisation = {
cores = 2;
@@ -49,6 +51,11 @@
users.users.alice.extraGroups = [ "wheel" ];
users.users.bob.extraGroups = [ "wheel" ];
# Needed for --elevate=run0. NixOS's default polkit admin rule is
# `unix-group:wheel`, so bob (in wheel) can authenticate with his
# own password via polkit-stdin-agent.
system.tools.nixos-rebuild.enableRun0Elevation = true;
# Disable sudo for root to ensure sudo isn't called without `--sudo`
security.sudo.extraRules = lib.mkForce [
{
@@ -142,6 +149,7 @@
deployer.copy_from_host("${configFile "config-1-deployed"}", "/root/configuration-1.nix")
deployer.copy_from_host("${configFile "config-2-deployed"}", "/root/configuration-2.nix")
deployer.copy_from_host("${configFile "config-3-deployed"}", "/root/configuration-3.nix")
deployer.copy_from_host("${configFile "config-4-deployed"}", "/root/configuration-4.nix")
deployer.copy_from_host("${targetNetworkJSON}", "/root/target-network.json")
deployer.copy_from_host("${targetConfigJSON}", "/root/target-configuration.json")
@@ -168,6 +176,20 @@
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
assert target_hostname == "config-3-deployed", f"{target_hostname=}"
with subtest("Deploy to bob@target with run0 and password"):
# polkit-stdin-agent registers an agent for systemd-run on the
# target and answers the PAM conversation with the password we
# supply locally. The agent is resolved on the target from
# <toplevel>/sw/bin (see Run0Elevator._remote_agent_argv).
deployer.send_chars("nixos-rebuild switch -I nixos-config=/root/configuration-4.nix --target-host bob@target --elevate=run0 --ask-elevate-password\n")
deployer.wait_until_tty_matches("1", "\\[run0\\] password for bob@target")
deployer.send_chars("${nodes.target.users.users.bob.password}\n")
deployer.wait_until_tty_matches("1", "Done. The new configuration is /nix/store/.*config-4-deployed")
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname").rstrip()
assert target_hostname == "config-4-deployed", f"{target_hostname=}"
# The target-arch agent is reachable at the stable sw/bin path.
target.succeed("test -x /run/current-system/sw/bin/polkit-stdin-agent")
with subtest("Deploy works with very long TMPDIR"):
tmp_dir = "/var/folder/veryveryveryveryverylongpathnamethatdoesnotworkwithcontrolpath"
deployer.succeed(f"mkdir -p {tmp_dir}")

View File

@@ -1,6 +1,5 @@
{
lib,
stdenv,
callPackage,
installShellFiles,
mkShell,

View File

@@ -6,6 +6,12 @@ controlling terminal is available), and how to feed it a pre-supplied
password when the backend supports that. ``run_wrapper`` and its callers
carry a single ``elevate: Elevator`` value and let it produce the command
prefix and stdin.
The remote case has no controlling terminal and the elevated command's
environment depends on the backend (``sudo`` inherits the SSH login env,
while the run0 backend starts a transient unit with only systemd's
default ``PATH``), so each backend builds the full remote argv itself
via :meth:`Elevator.wrap_remote`.
"""
from __future__ import annotations
@@ -14,24 +20,82 @@ import getpass
import os
import shlex
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Final, Self, override
from pathlib import Path, PurePosixPath
from typing import ClassVar, Final, Literal, Self, override
# Kept here (rather than in process.py) so that elevators can build remote
# argvs without a circular import.
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
type Args = Sequence[Arg]
class _Env(Enum):
PRESERVE_ENV = "PRESERVE"
@override
def __repr__(self) -> str:
return self.value
#: Sentinel meaning "copy this variable from the environment the wrapped
#: command would naturally see" (``os.environ`` locally, the SSH login
#: shell's environment remotely).
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
def _remote_env_shell_argv(
prefix: Sequence[str],
env: Mapping[str, EnvValue],
args: Args,
) -> list[Arg]:
"""Build ``<prefix> /bin/sh -c 'exec /usr/bin/env -i K=V… "$@"' sh <args>``.
The wrapper runs in the SSH login session, resolves ``PRESERVE_ENV``
variables against that session's environment, and re-execs the command
with exactly that set. ``/usr/bin/env`` is referenced by absolute path so
the wrapper does not depend on ``PATH`` itself (provided on NixOS via
``environment.usrbinenv``).
"""
assigns: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
assigns.append(f'{k}="${{{k}-}}"')
else:
assigns.append(f"{k}={shlex.quote(v)}")
script = f'exec /usr/bin/env -i {" ".join(assigns)} "$@"'
return [*prefix, "/bin/sh", "-c", script, "sh", *args]
@dataclass(frozen=True)
class Wrapped:
"""Result of wrapping a command for elevation."""
"""Result of wrapping a command for local elevation."""
#: Arguments to prepend to the command. Kept as ``list[str]`` rather than
#: the wider ``process.Args`` because elevators only ever prepend plain
#: strings; callers splice this into their existing ``Args`` list.
#: Arguments to prepend to the command.
prefix: list[str]
#: Text to send on the wrapped command's stdin (typically a password
#: followed by a newline), or ``None`` to leave stdin alone.
stdin: str | None = None
@dataclass(frozen=True)
class RemoteWrapped:
"""Result of wrapping a command for remote elevation over SSH.
Unlike :class:`Wrapped` this carries the *full* remote argv: backends
differ in where the env-resolution shell wrapper must sit relative to
the elevator (inside ``sudo``, but *around* ``systemd-run``), so a
plain prefix is not expressive enough.
"""
argv: list[Arg]
stdin: str | None = None
class Elevator(ABC):
"""How to gain root for activation commands."""
@@ -54,13 +118,18 @@ class Elevator(ABC):
"""Wrap a command run on the local machine."""
@abstractmethod
def wrap_remote(self) -> Wrapped:
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
"""Wrap a command run on a target host over SSH.
The remote side has no controlling terminal, so backends that need
interactive prompts must either accept a pre-supplied password
(see :meth:`with_password`) or rely on a passwordless policy on
the target.
*env* is the environment to establish for the elevated command.
:data:`PRESERVE_ENV` values are resolved against the SSH login
shell's environment, and the backend must do so before any step
that replaces it with a service-style one.
"""
@abstractmethod
@@ -83,6 +152,17 @@ class Elevator(ABC):
password = getpass.getpass(f"[{self.name}] password for {host_label}: ")
return self.with_password(password)
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
"""Return a copy bound to the toplevel being activated on the target.
Backends that need a helper binary on the remote
(:class:`Run0Elevator`'s ``polkit-stdin-agent``) use this to find
a target-architecture copy inside the just-copied closure. No-op
by default.
"""
del toplevel # unused in the base implementation
return self
def on_remote_failure(self) -> str | None:
"""Optional hint to print when a remote elevated command fails."""
return None
@@ -106,8 +186,8 @@ class NoElevator(Elevator):
return Wrapped(prefix=[])
@override
def wrap_remote(self) -> Wrapped:
return Wrapped(prefix=[])
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
return RemoteWrapped(argv=_remote_env_shell_argv([], env, args))
@override
def with_password(self, password: str) -> Self:
@@ -134,13 +214,6 @@ class SudoElevator(Elevator):
def wrap_local(self) -> Wrapped:
# Local sudo can prompt on /dev/tty itself, so the password is
# only piped when one was supplied explicitly.
return self._wrap()
@override
def wrap_remote(self) -> Wrapped:
return self._wrap()
def _wrap(self) -> Wrapped:
if self.password is not None:
return Wrapped(
prefix=["sudo", "--prompt=", "--stdin", *self.extra_opts],
@@ -148,6 +221,21 @@ class SudoElevator(Elevator):
)
return Wrapped(prefix=["sudo", *self.extra_opts])
@override
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
# sudo runs inside the SSH login session, so the env wrapper can
# sit *inside* it and ${VAR-} resolves against the login env.
if self.password is not None:
prefix = ["sudo", "--prompt=", "--stdin", *self.extra_opts]
stdin = self.password + "\n"
else:
prefix = ["sudo", *self.extra_opts]
stdin = None
return RemoteWrapped(
argv=_remote_env_shell_argv(prefix, env, args),
stdin=stdin,
)
@override
def with_password(self, password: str) -> Self:
return replace(self, password=password)
@@ -162,16 +250,144 @@ class SudoElevator(Elevator):
return None
@dataclass(frozen=True)
class Run0Elevator(Elevator):
"""Wrap with systemd's polkit-based ``run0``.
Locally, ``run0`` is used directly and the user's polkit agent
(graphical or ``pkttyagent``) handles any prompts.
Remotely we spell out the explicit ``systemd-run --uid=0 --pipe``
form instead. ``run0`` would internally do the same thing when stdio
is not a TTY (see systemd ``src/run/run.c``), but going through
``systemd-run`` directly gives us ``--setenv K=V``, which we need to
forward the SSH login environment into the transient unit (whose own
``PATH`` on NixOS is just the systemd store path), and keeps the
argv independent of whether the SSH session happens to have a TTY.
Authorisation comes from either a polkit rule on the target granting
``org.freedesktop.systemd1.manage-units`` to the deploying user, or
from ``polkit-stdin-agent`` for ``--ask-elevate-password``.
"""
name: str = "run0"
password: str | None = None
#: ``${toplevel}/sw/bin/polkit-stdin-agent`` on the target, set via
#: :meth:`for_target_config`. ``None`` falls back to the target's ``PATH``.
remote_agent: str | None = None
#: Non-interactive equivalent of ``run0`` (see the class docstring).
REMOTE_BASE: ClassVar[tuple[str, ...]] = (
"systemd-run",
"--uid=0",
"--pipe",
"--quiet",
"--wait",
"--collect",
"--service-type=exec",
"--send-sighup",
)
@override
def wrap_local(self) -> Wrapped:
if self.password is not None:
# Resolved from PATH, same requirement as the remote case: the
# machine doing the elevation needs
# system.tools.nixos-rebuild.enableRun0Elevation.
return Wrapped(
prefix=["polkit-stdin-agent", "--password-fd=0", "--", "run0", "--"],
stdin=self.password + "\n",
)
return Wrapped(prefix=["run0", "--"])
@override
def wrap_remote(self, env: Mapping[str, EnvValue], args: Args) -> RemoteWrapped:
# /bin/sh wrapper resolves PRESERVE_ENV in the SSH login session
# and forwards the result into the unit via --setenv.
setenvs: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
setenvs.append(f'--setenv={k}="${{{k}-}}"')
else:
setenvs.append(f"--setenv={shlex.quote(f'{k}={v}')}")
script = f'exec {shlex.join(self.REMOTE_BASE)} {" ".join(setenvs)} -- "$@"'
argv: list[Arg] = ["/bin/sh", "-c", script, "sh", *args]
if self.password is not None:
# polkit has no `sudo --stdin` equivalent. polkit-stdin-agent
# registers a per-process agent for the wrapped command and
# answers the PAM conversation from its stdin.
argv = self._remote_agent_argv(argv)
return RemoteWrapped(argv=argv, stdin=self.password + "\n")
return RemoteWrapped(argv=argv)
#: POSIX sh fragment that picks the first runnable agent from the
#: positional parameters up to ``--`` and execs it with the remainder.
#: ``command -v`` covers both absolute paths and ``PATH`` lookups.
_AGENT_PICKER: ClassVar[str] = (
"agent=; "
"for a; do "
"shift; "
'[ "$a" = -- ] && break; '
'[ -z "$agent" ] && command -v "$a" >/dev/null 2>&1 && agent="$a"; '
"done; "
'[ -n "$agent" ] && exec "$agent" --password-fd=0 -- "$@"; '
'echo "nixos-rebuild: polkit-stdin-agent not found on target host '
'(set system.tools.nixos-rebuild.enableRun0Elevation = true)" >&2; '
"exit 127"
)
def _remote_agent_argv(self, inner: list[Arg]) -> list[Arg]:
"""Wrap *inner* in a target-side agent lookup.
The deployer's own agent may be the wrong arch/nixpkgs (cross-arch
deploys, Darwin deployers, ``--no-reexec``), so resolve on the
target instead: first ``${toplevel}/sw/bin/polkit-stdin-agent``
(present when ``system.tools.nixos-rebuild.enableRun0Elevation``
is set), then bare ``polkit-stdin-agent`` on the SSH login PATH.
:data:`_AGENT_PICKER` exits 127 with a hint if neither is found.
"""
candidates: list[str] = []
if self.remote_agent is not None:
candidates.append(self.remote_agent)
candidates.append("polkit-stdin-agent")
return ["/bin/sh", "-c", self._AGENT_PICKER, "sh", *candidates, "--", *inner]
@override
def with_password(self, password: str) -> Self:
return replace(self, password=password)
@override
def for_target_config(self, toplevel: PurePosixPath | Path) -> Self:
return replace(
self, remote_agent=str(toplevel / "sw" / "bin" / "polkit-stdin-agent")
)
@override
def on_remote_failure(self) -> str | None:
if self.password is None:
return (
"while running command with remote run0. Either pass "
"--ask-elevate-password, or grant the deploying user the "
"polkit action 'org.freedesktop.systemd1.manage-units' on "
"the target host (security.polkit.extraConfig)."
)
return (
"while running command with remote run0. If the error above "
"mentions polkit-stdin-agent or PolicyKit1, the target host "
"needs system.tools.nixos-rebuild.enableRun0Elevation = true."
)
class ElevatorKind(Enum):
"""CLI-selectable elevation backends.
The enum *value* is the :class:`Elevator` subclass to instantiate,
``str(member)`` is what ``--elevate`` accepts on the command line.
Extended by later commits.
"""
NONE = NoElevator
SUDO = SudoElevator
RUN0 = Run0Elevator
@override
def __str__(self) -> str:

View File

@@ -14,7 +14,7 @@ from textwrap import dedent
from typing import Final, Literal
from . import tmpdir
from .elevate import NO_ELEVATOR, Elevator
from .elevate import NO_ELEVATOR, PRESERVE_ENV, Elevator
from .models import (
Action,
BuildAttr,
@@ -26,7 +26,7 @@ from .models import (
Profile,
Remote,
)
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
from .process import SSH_DEFAULT_OPTS, run_wrapper
from .utils import Args, dict_to_flags
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]

View File

@@ -4,14 +4,20 @@ import os
import re
import shlex
import subprocess
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from ipaddress import AddressValueError, IPv6Address
from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override
from typing import Final, Self, TextIO, TypedDict, Unpack
from . import tmpdir
from .elevate import NO_ELEVATOR, Elevator
from .elevate import (
NO_ELEVATOR,
PRESERVE_ENV,
Arg,
Args,
Elevator,
EnvValue,
)
logger: Final = logging.getLogger(__name__)
@@ -25,22 +31,6 @@ SSH_DEFAULT_OPTS: Final = [
]
class _Env(Enum):
PRESERVE_ENV = "PRESERVE"
@override
def __repr__(self) -> str:
return self.value
PRESERVE_ENV: Final = _Env.PRESERVE_ENV
type Arg = str | bytes | os.PathLike[str] | os.PathLike[bytes]
type Args = Sequence[Arg]
type EnvValue = str | Literal[_Env.PRESERVE_ENV]
@dataclass(frozen=True)
class Remote:
host: str
@@ -136,16 +126,9 @@ def run_wrapper(
resolved_env = _resolve_env_local(normalized_env)
if remote:
wrapped = elevate.wrap_remote()
process_input = wrapped.stdin
remote_run_args: list[Arg] = [
*wrapped.prefix,
"/bin/sh",
"-c",
_remote_shell_script(normalized_env),
"sh",
*run_args,
]
rwrapped = elevate.wrap_remote(normalized_env, run_args)
process_input = rwrapped.stdin
remote_run_args: list[Arg] = rwrapped.argv
ssh_args: list[Arg] = [
"ssh",
@@ -246,7 +229,7 @@ def _resolve_env_local(env: dict[str, EnvValue]) -> dict[str, str]:
return result
def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Arg]:
def _prefix_env_cmd(cmd: Args, resolved_env: dict[str, str]) -> list[Arg]:
"""
Prefix a command with `env -i K=V ... -- <cmd...>` to set vars for the
command.
@@ -258,24 +241,6 @@ def _prefix_env_cmd(cmd: Sequence[Arg], resolved_env: dict[str, str]) -> list[Ar
return ["env", "-i", *assigns, *cmd]
def _remote_shell_script(env: Mapping[str, EnvValue]) -> str:
"""
Build the POSIX shell wrapper used for remote execution over SSH.
SSH sends the remote command as a shell-interpreted command line, so we
need a wrapper to establish a clean environment before `exec`-ing the real
command. This wrapper is always run under `/bin/sh -c` so preserved
variables like `${PATH-}` do not depend on the remote user's login shell.
"""
shell_assigns: list[str] = []
for k, v in env.items():
if v is PRESERVE_ENV:
shell_assigns.append(f'{k}="${{{k}-}}"')
else:
shell_assigns.append(f"{k}={shlex.quote(v)}")
return f'exec env -i {" ".join(shell_assigns)} "$@"'
def _quote_remote_arg(arg: Arg) -> str:
return shlex.quote(str(arg))

View File

@@ -298,6 +298,11 @@ def build_and_activate_system(
copy_flags=grouped_nix_args.copy_flags,
)
elif args.rollback:
if target_host is not None:
# The elevated `nix-env --rollback` runs before path_to_config
# is known, so point the elevator at the profile to find a
# target-arch helper in the *current* generation's sw/bin.
args.elevator = args.elevator.for_target_config(profile.path)
path_to_config = _rollback_system(
action=action,
args=args,
@@ -315,6 +320,11 @@ def build_and_activate_system(
grouped_nix_args=grouped_nix_args,
)
if target_host is not None and not args.rollback:
# Prefer the helper from the toplevel we just copied to the
# target (correct arch, independent of re-exec / nixpkgs pin).
args.elevator = args.elevator.for_target_config(path_to_config)
current_config = Path("/run/current-system")
if args.diff:
if current_config.exists():

View File

@@ -1,3 +1,5 @@
from pathlib import PurePosixPath
import pytest
from pytest import MonkeyPatch
@@ -8,7 +10,10 @@ def test_no_elevator() -> None:
n = e.NoElevator()
assert not n.elevates
assert n.wrap_local() == e.Wrapped(prefix=[])
assert n.wrap_remote() == e.Wrapped(prefix=[])
rw = n.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.stdin is None
assert rw.argv[:2] == ["/bin/sh", "-c"]
assert rw.argv[-1] == "cmd"
with pytest.raises(e.ElevateError):
n.with_password("x")
@@ -19,14 +24,16 @@ def test_sudo_elevator(monkeypatch: MonkeyPatch) -> None:
s = e.SudoElevator()
assert s.elevates
assert s.wrap_local() == e.Wrapped(prefix=["sudo"])
assert s.wrap_remote() == e.Wrapped(prefix=["sudo"])
rw = s.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[0] == "sudo"
assert rw.argv[1:3] == ["/bin/sh", "-c"]
assert rw.stdin is None
assert s.on_remote_failure() is not None
sp = s.with_password("hunter2")
assert sp.wrap_remote() == e.Wrapped(
prefix=["sudo", "--prompt=", "--stdin"],
stdin="hunter2\n",
)
rw = sp.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[:3] == ["sudo", "--prompt=", "--stdin"]
assert rw.stdin == "hunter2\n"
assert sp.on_remote_failure() is None
# original unchanged
assert s.password is None
@@ -36,18 +43,94 @@ def test_sudo_elevator_extra_opts(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("NIX_SUDOOPTS", "--preserve-env=FOO -H")
s = e.SudoElevator()
assert s.wrap_local() == e.Wrapped(prefix=["sudo", "--preserve-env=FOO", "-H"])
assert s.with_password("p").wrap_remote() == e.Wrapped(
prefix=["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"],
stdin="p\n",
rw = s.with_password("p").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.argv[:5] == ["sudo", "--prompt=", "--stdin", "--preserve-env=FOO", "-H"]
assert rw.stdin == "p\n"
def test_run0_elevator() -> None:
r = e.Run0Elevator()
assert r.elevates
assert r.wrap_local() == e.Wrapped(prefix=["run0", "--"])
assert r.on_remote_failure() is not None
rp = r.with_password("hunter2")
w = rp.wrap_local()
assert w.prefix[0] == "polkit-stdin-agent"
assert "run0" in w.prefix
assert w.stdin == "hunter2\n"
# With a password the failure hint points at the agent, not at -S.
hint = rp.on_remote_failure()
assert hint is not None and "polkit-stdin-agent" in hint
def test_run0_elevator_remote() -> None:
r = e.Run0Elevator()
# No password: /bin/sh wrapper around systemd-run, env passed via
# --setenv so it is resolved in the SSH login shell rather than
# inside the transient unit (which has a useless PATH on NixOS).
rw = r.wrap_remote(
{"PATH": e.PRESERVE_ENV, "NIXOS_INSTALL_BOOTLOADER": "1"},
["nix-env", "-p", "/profile"],
)
assert rw.stdin is None
assert rw.argv[:2] == ["/bin/sh", "-c"]
script = rw.argv[2]
assert isinstance(script, str)
assert script.startswith("exec systemd-run --uid=0 --pipe ")
assert '--setenv=PATH="${PATH-}"' in script
assert "--setenv=NIXOS_INSTALL_BOOTLOADER=1" in script
assert script.endswith(' -- "$@"')
assert rw.argv[3:] == ["sh", "nix-env", "-p", "/profile"]
# With password: an agent-picker /bin/sh wraps the inner /bin/sh, so
# the agent is registered for the inner shell (and the systemd-run it
# execs into). With no toplevel bound the only candidate is bare-name
# PATH lookup.
rw = r.with_password("pw").wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
assert rw.stdin == "pw\n"
assert rw.argv[:3] == ["/bin/sh", "-c", e.Run0Elevator._AGENT_PICKER]
sep = rw.argv.index("--")
assert rw.argv[3:sep] == ["sh", "polkit-stdin-agent"]
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
# Explicit values containing spaces are shell-quoted inside the
# script (the whole thing is later shlex.quoted again for SSH).
rw = r.wrap_remote({"FOO": "a b"}, ["cmd"])
script = rw.argv[2]
assert isinstance(script, str)
assert "--setenv='FOO=a b'" in script
def test_run0_for_target_config() -> None:
toplevel = PurePosixPath("/nix/store/aaaa-nixos-system")
r = e.Run0Elevator().with_password("pw").for_target_config(toplevel)
assert r.remote_agent == f"{toplevel}/sw/bin/polkit-stdin-agent"
rw = r.wrap_remote({"PATH": e.PRESERVE_ENV}, ["cmd"])
sep = rw.argv.index("--")
# Order matters: target-arch toplevel first, then PATH.
assert rw.argv[4:sep] == [
f"{toplevel}/sw/bin/polkit-stdin-agent",
"polkit-stdin-agent",
]
# Inner argv is preserved verbatim after the separator.
assert rw.argv[sep + 1 : sep + 3] == ["/bin/sh", "-c"]
assert rw.argv[-1] == "cmd"
# Non-run0 elevators ignore the toplevel.
s = e.SudoElevator()
assert s.for_target_config(toplevel) is s
def test_elevator_kind() -> None:
assert isinstance(e.ElevatorKind.from_name("sudo"), e.SudoElevator)
assert isinstance(e.ElevatorKind.from_name("run0"), e.Run0Elevator)
assert isinstance(e.ElevatorKind.from_name("none"), e.NoElevator)
with pytest.raises(e.ElevateError):
e.ElevatorKind.from_name("doas")
assert set(e.ElevatorKind.choices()) == {"none", "sudo"}
assert set(e.ElevatorKind.choices()) == {"none", "sudo", "run0"}
def test_with_prompted_password(monkeypatch: MonkeyPatch) -> None:

View File

@@ -28,6 +28,9 @@ def test_parse_args_elevate() -> None:
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=sudo"])
assert isinstance(r.elevator, nr.elevate.SudoElevator)
r, _ = nr.parse_args(["nixos-rebuild", "switch", "--elevate=run0"])
assert isinstance(r.elevator, nr.elevate.Run0Elevator)
# back-compat aliases
for flag in ("--sudo", "--use-remote-sudo"):
r, _ = nr.parse_args(["nixos-rebuild", "switch", flag])
@@ -691,7 +694,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"mktemp",
"-d",
@@ -710,7 +713,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-store",
"--realise",
@@ -730,7 +733,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"readlink",
"-f",
@@ -748,7 +751,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"rm",
"-rf",
@@ -781,7 +784,7 @@ def test_execute_nix_switch_build_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -800,7 +803,7 @@ def test_execute_nix_switch_build_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -818,7 +821,7 @@ def test_execute_nix_switch_build_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -907,7 +910,7 @@ def test_execute_nix_switch_flake_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -926,7 +929,7 @@ def test_execute_nix_switch_flake_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -944,7 +947,7 @@ def test_execute_nix_switch_flake_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
@@ -1032,7 +1035,7 @@ def test_execute_nix_switch_flake_build_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix",
"--extra-experimental-features",
@@ -1271,7 +1274,7 @@ def test_execute_build_dry_run_build_and_target_remote(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix",
"--extra-experimental-features",
@@ -1526,7 +1529,7 @@ def test_execute_switch_store_path_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-f",
@@ -1544,7 +1547,7 @@ def test_execute_switch_store_path_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"nix-env",
"-p",
@@ -1563,7 +1566,7 @@ def test_execute_switch_store_path_target_host(
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"-d",
@@ -1581,7 +1584,7 @@ def test_execute_switch_store_path_target_host(
"sudo",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),

View File

@@ -767,8 +767,8 @@ def test_switch_to_configuration_without_systemd_run(
mock_run.assert_called_with(
[profile_path / "bin/switch-to-configuration", "switch"],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
elevate=e.NO_ELEVATOR,
@@ -809,8 +809,8 @@ def test_switch_to_configuration_without_systemd_run(
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
elevate=SUDO,
@@ -845,8 +845,8 @@ def test_switch_to_configuration_with_systemd_run(
"switch",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
elevate=e.NO_ELEVATOR,
@@ -875,8 +875,8 @@ def test_switch_to_configuration_with_systemd_run(
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
elevate=SUDO,

View File

@@ -8,24 +8,41 @@ import nixos_rebuild.models as m
import nixos_rebuild.process as p
def test_remote_shell_script() -> None:
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV}) == (
'''exec env -i PATH="${PATH-}" "$@"'''
)
assert p._remote_shell_script(
def test_remote_env_shell_argv() -> None:
assert e._remote_env_shell_argv([], {"PATH": e.PRESERVE_ENV}, ["cmd"]) == [
"/bin/sh",
"-c",
'''exec /usr/bin/env -i PATH="${PATH-}" "$@"''',
"sh",
"cmd",
]
assert e._remote_env_shell_argv(
["sudo"],
{
"PATH": p.PRESERVE_ENV,
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"PATH": e.PRESERVE_ENV,
"LOCALE_ARCHIVE": e.PRESERVE_ENV,
"NIXOS_NO_CHECK": e.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
}
) == (
"""exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'''
)
assert p._remote_shell_script({"PATH": p.PRESERVE_ENV, "FOO": "some value"}) == (
'''exec env -i PATH="${PATH-}" FOO='some value' "$@"'''
)
},
["cmd", "arg"],
) == [
"sudo",
"/bin/sh",
"-c",
"""exec /usr/bin/env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" """
'''NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"''',
"sh",
"cmd",
"arg",
]
assert e._remote_env_shell_argv(
[], {"PATH": e.PRESERVE_ENV, "FOO": "some value"}, []
) == [
"/bin/sh",
"-c",
'''exec /usr/bin/env -i PATH="${PATH-}" FOO='some value' "$@"''',
"sh",
]
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
@@ -123,7 +140,7 @@ def test_run_wrapper(mock_run: Any) -> None:
"--",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
"--with",
@@ -156,7 +173,7 @@ def test_run_wrapper(mock_run: Any) -> None:
"--stdin",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" FOO=bar "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" FOO=bar "$@"'""",
"sh",
"test",
"--with",
@@ -248,6 +265,82 @@ def test_ssh_host() -> None:
assert remote.store_type == "ssh-ng"
@patch.dict(p.os.environ, {"PATH": "/path/to/bin"}, clear=True)
@patch("subprocess.run", autospec=True)
def test_run_wrapper_run0(mock_run: Any) -> None:
p.run_wrapper(["cmd", "arg"], elevate=e.Run0Elevator())
mock_run.assert_called_with(
["run0", "--", "cmd", "arg"],
check=True,
text=True,
errors="surrogateescape",
env=None,
input=None,
)
run0_script = (
"exec systemd-run --uid=0 --pipe --quiet --wait --collect "
"--service-type=exec --send-sighup "
'--setenv=PATH="${PATH-}" -- "$@"'
)
p.run_wrapper(
["cmd", "arg"],
elevate=e.Run0Elevator(),
remote=m.Remote("user@host", [], "ssh"),
)
mock_run.assert_called_with(
[
"ssh",
*p.SSH_DEFAULT_OPTS,
"user@host",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(run0_script),
"sh",
"cmd",
"arg",
],
check=True,
text=True,
errors="surrogateescape",
env=None,
input=None,
)
p.run_wrapper(
["cmd"],
elevate=e.Run0Elevator().with_password("pw"),
remote=m.Remote("user@host", [], "ssh"),
)
mock_run.assert_called_with(
[
"ssh",
*p.SSH_DEFAULT_OPTS,
"user@host",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(e.Run0Elevator._AGENT_PICKER),
"sh",
# No toplevel bound, so the only candidate is PATH lookup.
"polkit-stdin-agent",
"--",
"/bin/sh",
"-c",
p._quote_remote_arg(run0_script),
"sh",
"cmd",
],
check=True,
text=True,
errors="surrogateescape",
env=None,
input="pw\n",
)
@patch("subprocess.run", autospec=True)
def test_custom_sudo_args(mock_run: Any) -> None:
with patch.dict(
@@ -298,7 +391,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
"--args",
"/bin/sh",
"-c",
"""'exec env -i PATH="${PATH-}" "$@"'""",
"""'exec /usr/bin/env -i PATH="${PATH-}" "$@"'""",
"sh",
"test",
],