nixos-rebuild-ng: fix too long TMPDIR detection
This code tried to compute a `TMPDIR` short enough to guarantee OpenSSH could create a unix socket underneath it. However, that computation was flawed, as the logic predates our recent change from `%n` to `%C`: https://github.com/NixOS/nixpkgs/commit/f23bab46664a3a511fa10d3dcdfb8a9322b14502 I ended up refactoring things here to put the ssh logic close to the tmpdir logic. I think that's appropriate as they really need to stay in sync, and this will hopefully help future developers keep them in sync. (I briefly explored trying to write the code in a way that forces them to stay in sync, but I abandoned that, as it made the code too obtuse.) I've documented all the implementation details we depend on. I also removed our assumptions on the behavior of Python's `tempfile` module: we now just ask it for a tempdir and check the resulting length. Now nothing will have to change in the future if Python starts using 9 characters instead of 8 for avoiding name conflicts. For the record, we ran into this over in ngipkgs: https://github.com/ngi-nix/ngipkgs/issues/2300, which is what triggered this investigation.
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user