Files
nixpkgs/nixos/tests/systemd-confinement/default.nix
aszlig 270601f6c4 nixos/systemd-confinement: Fix with template units
Quoting from <https://github.com/NixOS/nixpkgs/issues/464323>:

> When using confinement.enable = true for an instanced systemd service,
> the 2nd instance will fail to start if the 1st instance is still
> running.
>
> This only happens with confinement.enable = true;. Removing this
> option causes both service instances to succeed. Maybe this happens
> because the /run/confinement/fortune directory is shared between the
> instances.

The reason why this happens is that the root directory is set to
"/run/confinement/${mkPathSafeName name}", which is the non-expanded
unit name rather than the full unit name with the instance in case of a
template unit.

So when a template unit "foo@.service" is involved, the root directory
is then "/run/confinement/foo_" regardless of instance, so
foo@bar.service uses the same directory as foo@baz.service and when the
first unit cleans up the root directory, it also makes it inaccessible
for the unit started afterwards.

I added a small property test to test concurrent invocations, so we
cover this case and other issues that might come up with template units
in a future refactor.

Signed-off-by: aszlig <aszlig@nix.build>
2025-12-05 16:52:08 +01:00

396 lines
14 KiB
Nix

import ../make-test-python.nix {
name = "systemd-confinement";
nodes.machine =
{
pkgs,
lib,
utils,
...
}:
let
testLib = pkgs.python3Packages.buildPythonPackage {
name = "confinement-testlib";
format = "setuptools";
unpackPhase = ''
cat > setup.py <<EOF
from setuptools import setup
setup(name='confinement-testlib', py_modules=["checkperms"])
EOF
cp ${./checkperms.py} checkperms.py
'';
};
mkTest =
name: testScript:
pkgs.writers.writePython3 "${name}.py"
{
libraries = [
pkgs.python3Packages.pytest
testLib
];
}
''
# This runs our test script by using pytest's assertion rewriting, so
# that whenever we use "assert <something>", the actual values are
# printed rather than getting a generic AssertionError or the need to
# pass an explicit assertion error message.
import ast
from pathlib import Path
from _pytest.assertion.rewrite import rewrite_asserts
script = Path('${pkgs.writeText "${name}-main.py" ''
import errno, os, pytest, signal
from subprocess import run
from checkperms import Accessibility, assert_permissions
${testScript}
''}') # noqa
filename = str(script)
source = script.read_bytes()
tree = ast.parse(source, filename=filename)
rewrite_asserts(tree, source, filename)
exec(compile(tree, filename, 'exec', dont_inherit=True))
'';
mkTestStep =
num:
{
description,
testScript,
config ? { },
serviceName ? "test${toString num}",
rawUnit ? null,
}:
{
systemd.packages = lib.optional (rawUnit != null) (
pkgs.writeTextFile {
name = serviceName;
destination = "/etc/systemd/system/${serviceName}.service";
text = rawUnit;
}
);
systemd.services.${serviceName} = {
inherit description;
requiredBy = [ "multi-user.target" ];
confinement = (config.confinement or { }) // {
enable = true;
};
serviceConfig = (config.serviceConfig or { }) // {
ExecStart = mkTest serviceName testScript;
Type = "oneshot";
};
}
// removeAttrs config [
"confinement"
"serviceConfig"
];
};
parametrisedTests =
lib.concatMap
(
{ user, privateTmp }:
let
withTmp = if privateTmp then "with PrivateTmp" else "without PrivateTmp";
serviceConfig =
if user == "static-user" then
{
User = "chroot-testuser";
Group = "chroot-testgroup";
}
else if user == "dynamic-user" then
{
DynamicUser = true;
}
else
{ };
in
[
{
description = "${user}, chroot-only confinement ${withTmp}";
config = {
confinement.mode = "chroot-only";
# Only set if privateTmp is true to ensure that the default is false.
serviceConfig =
serviceConfig
// lib.optionalAttrs privateTmp {
PrivateTmp = true;
};
};
testScript =
if user == "root" then
''
assert os.getuid() == 0
assert os.getgid() == 0
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'run': Accessibility.READABLE,
${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
})
''
else
''
assert os.getuid() != 0
assert os.getgid() != 0
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
'run': Accessibility.READABLE,
${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
})
'';
}
{
description = "${user}, full APIVFS confinement ${withTmp}";
config = {
# Only set if privateTmp is false to ensure that the default is true.
serviceConfig =
serviceConfig
// lib.optionalAttrs (!privateTmp) {
PrivateTmp = false;
};
};
testScript =
if user == "root" then
''
assert os.getuid() == 0
assert os.getgid() == 0
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
'run': Accessibility.WRITABLE,
'proc': Accessibility.SPECIAL,
'sys': Accessibility.SPECIAL,
'dev': Accessibility.WRITABLE,
${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
})
''
else
''
assert os.getuid() != 0
assert os.getgid() != 0
assert_permissions({
'bin': Accessibility.READABLE,
'nix': Accessibility.READABLE,
${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
'run': Accessibility.STICKY,
'proc': Accessibility.SPECIAL,
'sys': Accessibility.SPECIAL,
'dev': Accessibility.SPECIAL,
'dev/shm': Accessibility.STICKY,
'dev/mqueue': Accessibility.STICKY,
${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
})
'';
}
]
)
(
lib.cartesianProduct {
user = [
"root"
"dynamic-user"
"static-user"
];
privateTmp = [
true
false
];
}
);
concurrentRunner = pkgs.writers.writePython3 "concurrent-runner" {
libraries = [
pkgs.python3Packages.click
pkgs.python3Packages.hypothesis
];
} ./concurrent-runner.py;
concurrentTest = {
systemd.services.concurrent-driver = {
description = "Driver for orchestrating concurrent processes";
requiredBy = [ "multi-user.target" ];
after = [
"network.target"
"concurrent-client.socket"
];
serviceConfig.Type = "oneshot";
serviceConfig.ExecStart = utils.escapeSystemdExecArgs [
concurrentRunner
"driver"
];
};
systemd.sockets.concurrent-client = {
description = "Socket for concurrent processes";
requiredBy = [ "sockets.target" ];
socketConfig.ListenStream = 12345;
socketConfig.Accept = true;
};
systemd.services."concurrent-client@" = {
description = "Process %I running concurrently with others";
confinement.enable = true;
serviceConfig.StandardInput = "socket";
serviceConfig.StandardError = "journal";
serviceConfig.ExecStart = utils.escapeSystemdExecArgs [
concurrentRunner
"client"
"${pkgs.fortune}/bin/fortune"
];
};
};
in
{
imports =
lib.imap1 mkTestStep (
parametrisedTests
++ [
{
description = "existence of bind-mounted /etc";
config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
testScript = ''
assert Path('/etc/passwd').read_text()
'';
}
(
let
symlink = pkgs.runCommand "symlink" {
target = pkgs.writeText "symlink-target" "got me";
} "ln -s \"$target\" \"$out\"";
in
{
description = "check if symlinks are properly bind-mounted";
config.confinement.packages = lib.singleton symlink;
testScript = ''
assert Path('${symlink}').read_text() == 'got me'
'';
}
)
{
description = "check if StateDirectory works";
config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
config.serviceConfig.StateDirectory = "testme";
# We restart on purpose here since we want to check whether the state
# directory actually persists.
config.serviceConfig.Restart = "on-failure";
config.serviceConfig.RestartMode = "direct";
testScript = ''
assert not Path('/tmp/canary').exists()
Path('/tmp/canary').touch()
if (foo := Path('/var/lib/testme/foo')).exists():
assert Path('/var/lib/testme/foo').read_text() == 'works'
else:
Path('/var/lib/testme/foo').write_text('works')
print('<4>Exiting with failure to check persistence on restart.')
raise SystemExit(1)
'';
}
{
description = "check if /bin/sh works";
testScript = ''
assert Path('/bin/sh').exists()
result = run(
['/bin/sh', '-c', 'echo -n bar'],
capture_output=True,
check=True,
)
assert result.stdout == b'bar'
'';
}
{
description = "check if suppressing /bin/sh works";
config.confinement.binSh = null;
testScript = ''
assert not Path('/bin/sh').exists()
with pytest.raises(FileNotFoundError):
run(['/bin/sh', '-c', 'echo foo'])
'';
}
{
description = "check if we can set /bin/sh to something different";
config.confinement.binSh = "${pkgs.hello}/bin/hello";
testScript = ''
assert Path('/bin/sh').exists()
result = run(
['/bin/sh', '-g', 'foo'],
capture_output=True,
check=True,
)
assert result.stdout == b'foo\n'
'';
}
{
description = "check if only Exec* dependencies are included";
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
testScript = ''
with pytest.raises(FileNotFoundError):
Path(os.environ['FOOBAR']).read_text()
'';
}
{
description = "check if fullUnit includes all dependencies";
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
config.confinement.fullUnit = true;
testScript = ''
assert Path(os.environ['FOOBAR']).read_text() == 'eek'
'';
}
{
description = "check if shipped unit file still works";
config.confinement.mode = "chroot-only";
rawUnit = ''
[Service]
SystemCallFilter=~kill
SystemCallErrorNumber=ELOOP
'';
testScript = ''
with pytest.raises(OSError) as excinfo:
os.kill(os.getpid(), signal.SIGKILL)
assert excinfo.value.errno == errno.ELOOP
'';
}
]
)
++ [ concurrentTest ];
config.users.groups.chroot-testgroup = { };
config.users.users.chroot-testuser = {
isSystemUser = true;
description = "Chroot Test User";
group = "chroot-testgroup";
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
'';
}