From c30b2c06d953e6f986162c5809d864eae52e61b5 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 16 Apr 2026 20:32:13 +0200 Subject: [PATCH] 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 /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. --- nixos/modules/installer/tools/tools.nix | 21 ++ nixos/tests/nixos-rebuild-target-host.nix | 22 ++ pkgs/by-name/ni/nixos-rebuild-ng/package.nix | 1 - .../src/nixos_rebuild/elevate.py | 248 ++++++++++++++++-- .../nixos-rebuild-ng/src/nixos_rebuild/nix.py | 4 +- .../src/nixos_rebuild/process.py | 63 +---- .../src/nixos_rebuild/services.py | 10 + .../src/tests/test_elevate.py | 103 +++++++- .../nixos-rebuild-ng/src/tests/test_main.py | 35 +-- .../ni/nixos-rebuild-ng/src/tests/test_nix.py | 16 +- .../src/tests/test_process.py | 131 +++++++-- 11 files changed, 533 insertions(+), 121 deletions(-) diff --git a/nixos/modules/installer/tools/tools.nix b/nixos/modules/installer/tools/tools.nix index 086d8971d466..e7f48a3243ea 100644 --- a/nixos/modules/installer/tools/tools.nix +++ b/nixos/modules/installer/tools/tools.nix @@ -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}`/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; diff --git a/nixos/tests/nixos-rebuild-target-host.nix b/nixos/tests/nixos-rebuild-target-host.nix index c37c4ce2ee76..ae872a205a0e 100644 --- a/nixos/tests/nixos-rebuild-target-host.nix +++ b/nixos/tests/nixos-rebuild-target-host.nix @@ -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 + # /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}") diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix index 136962b1b7a9..8112f3d867f8 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/package.nix +++ b/pkgs/by-name/ni/nixos-rebuild-ng/package.nix @@ -1,6 +1,5 @@ { lib, - stdenv, callPackage, installShellFiles, mkShell, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py index be9e35fce613..c69eb097445d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/elevate.py @@ -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 `` /bin/sh -c 'exec /usr/bin/env -i K=V… "$@"' sh ``. + + 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: diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py index 38894b734133..c9d79868f8f4 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py @@ -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"] diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py index bad599b36643..61408ab8aaf5 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/process.py @@ -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 ... -- ` 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)) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py index 96d9d16e76f2..9994941ebb4d 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/services.py @@ -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(): diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py index 6d6231408adb..25a1c3260cda 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_elevate.py @@ -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: diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py index 27335feeae5d..1e0e5779ac90 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_main.py @@ -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"), diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py index 3d5a5093230b..8100f396cdb7 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_nix.py @@ -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, diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py index 4d6421146a8d..2abdd26ebe07 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_process.py @@ -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", ],