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 d848709ef8f1..7ba809d4cb9c 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 @@ -25,7 +25,7 @@ SSH_DEFAULT_OPTS: Final = [ "-o", "ControlMaster=auto", "-o", - f"ControlPath={tmpdir.TMPDIR_PATH / 'ssh-%C'}", + f"ControlPath={tmpdir.SSH_CONTROL_PATH}", "-o", "ControlPersist=60", ] diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/tmpdir.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/tmpdir.py index 9db6e310a4d2..1df30dcad7eb 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/tmpdir.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/tmpdir.py @@ -1,38 +1,86 @@ import logging +import os from pathlib import Path -from tempfile import TemporaryDirectory, gettempdir +from tempfile import TemporaryDirectory from typing import Final logger: Final = logging.getLogger(__name__) +# The Linux kernel hardcodes a limit of 108 bytes for Unix sockets [0], +# but that includes one NULL byte at the very end, so the logical max +# length is 107 bytes. +# [0]: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/uapi/linux/un.h?h=v6.18.37#n7 +LINUX_MAX_SOCKET_LENGTH: Final = 107 -# Very long tmp dirs lead to "too long for Unix domain socket" -# SSH ControlPath errors. Especially macOS sets long TMPDIR paths. -# This is also required for Linux, if the user tries to build -# from inside a shell using `--target-host`, which will cause -# ssh to fail with "ControlPath too long" +# OpenSSH expands %C to `conn_hash_hex` [0], +# which is the result of calling `ssh_connection_hash` [1], +# which computes a sha1 digest [2], +# which is 20 bytes long [3]. +# which gets hex encoded [4] to double that length [5]. # -# The constant is based on a worst case example FQDN, e.g.: -# `ec2-123-123-123-123.ap-southeast-2.compute.amazonaws.com` (56 bytes). -# The `ControlPath` can maximum be 108 bytes. Given the prefix -# that is used for the tempdir, ie. `nixos-rebuild.47i6dz8c` (22 bytes), -# we have 30 bytes left to work with. -# This should be fine for the usual temp folders: -# /tmp/tmp.7hBqN2Fm5H (19) -# /var/tmp/tmp.7hBqN2Fm5H (23) -# /run/user/1000/tmp.7hBqN2Fm5H (29) +# [0]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/sshconnect.h#L70 +# [1]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/ssh.c#L1464-L1465 +# [2]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/readconf.c#L345 +# [3]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/openbsd-compat/sha1.h#L15 +# [4]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/readconf.c#L360 +# [5]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/misc.c#L1627 +OPENSSH_PERCENT_C_LENGTH: Final = 40 + +# OpenSSH adds a suffix to the given control path [0]. +# There's 1 character for a `.` separator, followed by 16 random characters [1]. +# [0]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/mux.c#L1348 +# [1]: https://github.com/openssh/openssh-portable/blob/V_10_3_P1/mux.c#L1324 +OPENSSH_CONTROL_PATH_SUFFIX_LENGTH: Final = 1 + 16 + +# Carefully compute a maximum allowed length for a tmpdir, otherwise ssh crashes with errors like this: +# > unix_listener: path "/home/runner/work/_temp/nixos-rebuild.aw8hzmq7/ssh-ea7c10de83787b1dec3f06ef20ee26b38c6bb0a5.x9MDykk4gmASsl3R" +# > too long for Unix domain socket +# +# The full path to our tmpdir must be short enough for the resulting Unix domain +# sockets that OpenSSH creates to fit within `LINUX_MAX_SOCKET_LENGTH`. +# It's common for system configured temp dirs to be more +# than a few characters: +# - macOS sets long TMPDIR paths. +# - Nix dev shells set a longer TMPDIR +# - GitHub actions set TMPDIR to something like `/home/runner/work/_temp`. +# +# Breaking down the socket path into its component pieces: +# +# /home/runner/work/_temp/nixos-rebuild.aw8hzmq7/ssh-ea7c10de83787b1dec3f06ef20ee26b38c6bb0a5.x9MDykk4gmASsl3R +# |-------------- TMPDIR ----------------------|^|----------------- ssh-%C -----------------||---------------| +# | | +# Note the path separator character. OPENSSH_CONTROL_PATH_SUFFIX_LENGTH +# +MAX_TMPDIR_LENGTH: Final = ( + LINUX_MAX_SOCKET_LENGTH + - 1 # Path separator between tmpdir and the socket name. + # Keep the following in sync with `SSH_CONTROL_PATH`. + - len("ssh-") + - OPENSSH_PERCENT_C_LENGTH + - OPENSSH_CONTROL_PATH_SUFFIX_LENGTH +) + + def make_tmpdir() -> TemporaryDirectory[str]: - tmp = gettempdir() - if len(tmp) >= 30: + prefix = "nixos-rebuild." + tmpdir = TemporaryDirectory(prefix=prefix) + if len(os.fsencode(tmpdir.name)) > MAX_TMPDIR_LENGTH: + short_tmpdir = TemporaryDirectory(prefix=prefix, dir="/tmp") logger.debug( - "tempdir '%s' exceeds 30 bytes limit, defaulting to /tmp instead", - tmp, + "tempdir '%s' exceeds %s bytes limit, defaulting to '%s' instead", + tmpdir, + MAX_TMPDIR_LENGTH, + short_tmpdir, ) - return TemporaryDirectory(prefix="nixos-rebuild.", dir="/tmp") + tmpdir.cleanup() + return short_tmpdir - return TemporaryDirectory(prefix="nixos-rebuild.") + return tmpdir TMPDIR: Final = make_tmpdir() TMPDIR_PATH: Final = Path(TMPDIR.name) + +# Keep this in sync with `MAX_TMPDIR_LENGTH`! +SSH_CONTROL_PATH: Final = str(TMPDIR_PATH / "ssh-%C") diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_tmpdir.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_tmpdir.py new file mode 100644 index 000000000000..5735e7b979f3 --- /dev/null +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/tests/test_tmpdir.py @@ -0,0 +1,56 @@ +import contextlib +import os +import tempfile +import typing +from pathlib import Path + +from nixos_rebuild.tmpdir import MAX_TMPDIR_LENGTH, make_tmpdir + + +@contextlib.contextmanager +def system_tempdir(path: Path) -> typing.Generator[None]: + path.mkdir(exist_ok=True) + + # `tempfile` caches the tempdir, you must clear it for it to recompute. + tempfile.tempdir = None + + og_tmpdir = os.environ.get("TMPDIR") + + try: + os.environ["TMPDIR"] = str(path) + assert Path(tempfile.gettempdir()) == path + yield + finally: + if og_tmpdir is None: + del os.environ["TMPDIR"] + else: + os.environ["TMPDIR"] = og_tmpdir + + +def test_make_tmpdir() -> None: + # Basic test: whatever the default system temp dir happens to be. + tmpdir = make_tmpdir() + tmp = Path(tmpdir.name) + assert tmp.exists() + assert tmp.is_dir() + assert len(os.fsencode(str(tmp))) <= MAX_TMPDIR_LENGTH + + # Test with a short system temp dir. We should use it unmodified. + with system_tempdir(Path("/tmp/not-too-long")): + tmpdir = make_tmpdir() + tmp = Path(tmpdir.name) + + assert tmp.exists() + assert tmp.is_dir() + assert len(os.fsencode(str(tmp))) <= MAX_TMPDIR_LENGTH + + # Test with a long system temp dir. We should ignore + # it and fall back to something short enough for OpenSSH to + # create sockets in. + with system_tempdir(Path("/tmp/long" + ("g" * MAX_TMPDIR_LENGTH))): + tmpdir = make_tmpdir() + tmp = Path(tmpdir.name) + + assert tmp.exists() + assert tmp.is_dir() + assert len(os.fsencode(str(tmp))) <= MAX_TMPDIR_LENGTH