Merge pull request #289593 (confinement + DynamicUser)

This adds support for the systemd ProtectSystem and DynamicUser options
in conjunction with the systemd-confinement module, which has been a
limitation in the initial implementation and so far has thrown assertion
errors whenever those options were enabled.

Thanks to @ju1m, we now no longer need to resort to static users.

Review for this work took a little bit longer since I wanted to be
absolutely sure that we don't introduce any new regressions, which would
involve increasing the attack surface.

In the end however, we even managed to even lower the attack surface
even more since now the confined filesystem root is now read-only even
for the root user.
This commit is contained in:
aszlig 2024-05-13 00:42:48 +02:00
commit 2bb1556bf4
No known key found for this signature in database
GPG Key ID: 684089CE67EBB691
6 changed files with 486 additions and 199 deletions

View File

@ -713,6 +713,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m
- `documentation.man.mandoc` now by default uses `MANPATH` to set the directories where mandoc will search for manual pages.
This enables mandoc to find manual pages in Nix profiles. To set the manual search paths via the `mandoc.conf` configuration file like before, use `documentation.man.mandoc.settings.manpath` instead.
- The `systemd-confinement` module extension is now compatible with `DynamicUser=true` and thus `ProtectSystem=strict` too.
- `grafana-loki` package was updated to 3.0.0 which includes [breaking changes](https://github.com/grafana/loki/releases/tag/v3.0.0).
- `programs.fish.package` now allows you to override the package used in the `fish` module.

View File

@ -79,13 +79,20 @@ in {
description = ''
The value `full-apivfs` (the default) sets up
private {file}`/dev`, {file}`/proc`,
{file}`/sys` and {file}`/tmp` file systems in a separate user
name space.
{file}`/sys`, {file}`/tmp` and {file}`/var/tmp` file systems
in a separate user name space.
If this is set to `chroot-only`, only the file
system name space is set up along with the call to
{manpage}`chroot(2)`.
In all cases, unless `serviceConfig.PrivateTmp=true` is set,
both {file}`/tmp` and {file}`/var/tmp` paths are added to `InaccessiblePaths=`.
This is to overcome options like `DynamicUser=true`
implying `PrivateTmp=true` without letting it being turned off.
Beware however that giving processes the `CAP_SYS_ADMIN` and `@mount` privileges
can let them undo the effects of `InaccessiblePaths=`.
::: {.note}
This doesn't cover network namespaces and is solely for
file system level isolation.
@ -98,8 +105,12 @@ in {
wantsAPIVFS = lib.mkDefault (config.confinement.mode == "full-apivfs");
in lib.mkIf config.confinement.enable {
serviceConfig = {
RootDirectory = "/var/empty";
TemporaryFileSystem = "/";
ReadOnlyPaths = [ "+/" ];
RuntimeDirectory = [ "confinement/${mkPathSafeName name}" ];
RootDirectory = "/run/confinement/${mkPathSafeName name}";
InaccessiblePaths = [
"-+/run/confinement/${mkPathSafeName name}"
];
PrivateMounts = lib.mkDefault true;
# https://github.com/NixOS/nixpkgs/issues/14645 is a future attempt
@ -148,16 +159,6 @@ in {
+ " Please either define a separate service or find a way to run"
+ " commands other than ExecStart within the chroot.";
}
{ assertion = !cfg.serviceConfig.DynamicUser or false;
message = "${whatOpt "DynamicUser"}. Please create a dedicated user via"
+ " the 'users.users' option instead as this combination is"
+ " currently not supported.";
}
{ assertion = cfg.serviceConfig ? ProtectSystem -> cfg.serviceConfig.ProtectSystem == false;
message = "${whatOpt "ProtectSystem"}. ProtectSystem is not compatible"
+ " with service confinement as it fails to remount /usr within"
+ " our chroot. Please disable the option.";
}
]) config.systemd.services);
config.systemd.packages = lib.concatLists (lib.mapAttrsToList (name: cfg: let
@ -183,6 +184,13 @@ in {
echo "BindReadOnlyPaths=$realprog:/bin/sh" >> "$serviceFile"
''}
# If DynamicUser= is enabled, PrivateTmp=true is implied (and cannot be turned off).
# so disable them unless PrivateTmp=true is explicitely set.
${lib.optionalString (!cfg.serviceConfig.PrivateTmp) ''
echo "InaccessiblePaths=-+/tmp" >> "$serviceFile"
echo "InaccessiblePaths=-+/var/tmp" >> "$serviceFile"
''}
while read storePath; do
if [ -L "$storePath" ]; then
# Currently, systemd can't cope with symlinks in Bind(ReadOnly)Paths,

View File

@ -885,7 +885,7 @@ in {
systemd-binfmt = handleTestOn ["x86_64-linux"] ./systemd-binfmt.nix {};
systemd-boot = handleTest ./systemd-boot.nix {};
systemd-bpf = handleTest ./systemd-bpf.nix {};
systemd-confinement = handleTest ./systemd-confinement.nix {};
systemd-confinement = handleTest ./systemd-confinement {};
systemd-coredump = handleTest ./systemd-coredump.nix {};
systemd-cryptenroll = handleTest ./systemd-cryptenroll.nix {};
systemd-credentials-tpm2 = handleTest ./systemd-credentials-tpm2.nix {};

View File

@ -1,184 +0,0 @@
import ./make-test-python.nix {
name = "systemd-confinement";
nodes.machine = { pkgs, lib, ... }: let
testServer = pkgs.writeScript "testserver.sh" ''
#!${pkgs.runtimeShell}
export PATH=${lib.escapeShellArg "${pkgs.coreutils}/bin"}
${lib.escapeShellArg pkgs.runtimeShell} 2>&1
echo "exit-status:$?"
'';
testClient = pkgs.writeScriptBin "chroot-exec" ''
#!${pkgs.runtimeShell} -e
output="$(echo "$@" | nc -NU "/run/test$(< /teststep).sock")"
ret="$(echo "$output" | sed -nre '$s/^exit-status:([0-9]+)$/\1/p')"
echo "$output" | head -n -1
exit "''${ret:-1}"
'';
mkTestStep = num: {
testScript,
config ? {},
serviceName ? "test${toString num}",
}: {
systemd.sockets.${serviceName} = {
description = "Socket for Test Service ${toString num}";
wantedBy = [ "sockets.target" ];
socketConfig.ListenStream = "/run/test${toString num}.sock";
socketConfig.Accept = true;
};
systemd.services."${serviceName}@" = {
description = "Confined Test Service ${toString num}";
confinement = (config.confinement or {}) // { enable = true; };
serviceConfig = (config.serviceConfig or {}) // {
ExecStart = testServer;
StandardInput = "socket";
};
} // removeAttrs config [ "confinement" "serviceConfig" ];
__testSteps = lib.mkOrder num (''
machine.succeed("echo ${toString num} > /teststep")
'' + testScript);
};
in {
imports = lib.imap1 mkTestStep [
{ config.confinement.mode = "chroot-only";
testScript = ''
with subtest("chroot-only confinement"):
paths = machine.succeed('chroot-exec ls -1 / | paste -sd,').strip()
assert_eq(paths, "bin,nix,run")
uid = machine.succeed('chroot-exec id -u').strip()
assert_eq(uid, "0")
machine.succeed("chroot-exec chown 65534 /bin")
'';
}
{ testScript = ''
with subtest("full confinement with APIVFS"):
machine.fail("chroot-exec ls -l /etc")
machine.fail("chroot-exec chown 65534 /bin")
assert_eq(machine.succeed('chroot-exec id -u').strip(), "0")
machine.succeed("chroot-exec chown 0 /bin")
'';
}
{ config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
testScript = ''
with subtest("check existence of bind-mounted /etc"):
passwd = machine.succeed('chroot-exec cat /etc/passwd').strip()
assert len(passwd) > 0, "/etc/passwd must not be empty"
'';
}
{ config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
testScript = ''
with subtest("check if User/Group really runs as non-root"):
machine.succeed("chroot-exec ls -l /dev")
uid = machine.succeed('chroot-exec id -u').strip()
assert uid != "0", "UID of chroot-testuser shouldn't be 0"
machine.fail("chroot-exec touch /bin/test")
'';
}
(let
symlink = pkgs.runCommand "symlink" {
target = pkgs.writeText "symlink-target" "got me\n";
} "ln -s \"$target\" \"$out\"";
in {
config.confinement.packages = lib.singleton symlink;
testScript = ''
with subtest("check if symlinks are properly bind-mounted"):
machine.fail("chroot-exec test -e /etc")
text = machine.succeed('chroot-exec cat ${symlink}').strip()
assert_eq(text, "got me")
'';
})
{ config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
config.serviceConfig.StateDirectory = "testme";
testScript = ''
with subtest("check if StateDirectory works"):
machine.succeed("chroot-exec touch /tmp/canary")
machine.succeed('chroot-exec "echo works > /var/lib/testme/foo"')
machine.succeed('test "$(< /var/lib/testme/foo)" = works')
machine.succeed("test ! -e /tmp/canary")
'';
}
{ testScript = ''
with subtest("check if /bin/sh works"):
machine.succeed(
"chroot-exec test -e /bin/sh",
'test "$(chroot-exec \'/bin/sh -c "echo bar"\')" = bar',
)
'';
}
{ config.confinement.binSh = null;
testScript = ''
with subtest("check if suppressing /bin/sh works"):
machine.succeed("chroot-exec test ! -e /bin/sh")
machine.succeed('test "$(chroot-exec \'/bin/sh -c "echo foo"\')" != foo')
'';
}
{ config.confinement.binSh = "${pkgs.hello}/bin/hello";
testScript = ''
with subtest("check if we can set /bin/sh to something different"):
machine.succeed("chroot-exec test -e /bin/sh")
machine.succeed('test "$(chroot-exec /bin/sh -g foo)" = foo')
'';
}
{ config.environment.FOOBAR = pkgs.writeText "foobar" "eek\n";
testScript = ''
with subtest("check if only Exec* dependencies are included"):
machine.succeed('test "$(chroot-exec \'cat "$FOOBAR"\')" != eek')
'';
}
{ config.environment.FOOBAR = pkgs.writeText "foobar" "eek\n";
config.confinement.fullUnit = true;
testScript = ''
with subtest("check if all unit dependencies are included"):
machine.succeed('test "$(chroot-exec \'cat "$FOOBAR"\')" = eek')
'';
}
{ serviceName = "shipped-unitfile";
config.confinement.mode = "chroot-only";
testScript = ''
with subtest("check if shipped unit file still works"):
machine.succeed(
'chroot-exec \'kill -9 $$ 2>&1 || :\' | '
'grep -q "Too many levels of symbolic links"'
)
'';
}
];
options.__testSteps = lib.mkOption {
type = lib.types.lines;
description = "All of the test steps combined as a single script.";
};
config.environment.systemPackages = lib.singleton testClient;
config.systemd.packages = lib.singleton (pkgs.writeTextFile {
name = "shipped-unitfile";
destination = "/etc/systemd/system/shipped-unitfile@.service";
text = ''
[Service]
SystemCallFilter=~kill
SystemCallErrorNumber=ELOOP
'';
});
config.users.groups.chroot-testgroup = {};
config.users.users.chroot-testuser = {
isSystemUser = true;
description = "Chroot Test User";
group = "chroot-testgroup";
};
};
testScript = { nodes, ... }: ''
def assert_eq(a, b):
assert a == b, f"{a} != {b}"
machine.wait_for_unit("multi-user.target")
'' + nodes.machine.config.__testSteps;
}

View File

@ -0,0 +1,187 @@
import errno
import os
from enum import IntEnum
from pathlib import Path
class Accessibility(IntEnum):
"""
The level of accessibility we have on a file or directory.
This is needed to assess the attack surface on the file system namespace we
have within a confined service. Higher levels mean more permissions for the
user and thus a bigger attack surface.
"""
NONE = 0
# Directories can be listed or files can be read.
READABLE = 1
# This is for special file systems such as procfs and for stuff such as
# FIFOs or character special files. The reason why this has a lower value
# than WRITABLE is because those files are more restricted on what and how
# they can be written to.
SPECIAL = 2
# Another special case are sticky directories, which do allow write access
# but restrict deletion. This does *not* apply to sticky directories that
# are read-only.
STICKY = 3
# Essentially full permissions, the kind of accessibility we want to avoid
# in most cases.
WRITABLE = 4
def assert_on(self, path: Path) -> None:
"""
Raise an AssertionError if the given 'path' allows for more
accessibility than 'self'.
"""
actual = self.NONE
if path.is_symlink():
actual = self.READABLE
elif path.is_dir():
writable = True
dummy_file = path / 'can_i_write'
try:
dummy_file.touch()
except OSError as e:
if e.errno in [errno.EROFS, errno.EACCES]:
writable = False
else:
raise
else:
dummy_file.unlink()
if writable:
# The reason why we test this *after* we made sure it's
# writable is because we could have a sticky directory where
# the current user doesn't have write access.
if path.stat().st_mode & 0o1000 == 0o1000:
actual = self.STICKY
else:
actual = self.WRITABLE
else:
actual = self.READABLE
elif path.is_file():
try:
with path.open('rb') as fp:
fp.read(1)
actual = self.READABLE
except PermissionError:
pass
writable = True
try:
with path.open('ab') as fp:
fp.write('x')
size = fp.tell()
fp.truncate(size)
except PermissionError:
writable = False
except OSError as e:
if e.errno == errno.ETXTBSY:
writable = os.access(path, os.W_OK)
elif e.errno == errno.EROFS:
writable = False
else:
raise
# Let's always try to fail towards being writable, so if *either*
# access(2) or a real write is successful it's writable. This is to
# make sure we don't accidentally introduce no-ops if we have bugs
# in the more complicated real write code above.
if writable or os.access(path, os.W_OK):
actual = self.WRITABLE
else:
# We need to be very careful when writing to or reading from
# special files (eg. FIFOs), since they can possibly block. So if
# it's not a file, just trust that access(2) won't lie.
if os.access(path, os.R_OK):
actual = self.READABLE
if os.access(path, os.W_OK):
actual = self.SPECIAL
if actual > self:
stat = path.stat()
details = ', '.join([
f'permissions: {stat.st_mode & 0o7777:o}',
f'uid: {stat.st_uid}',
f'group: {stat.st_gid}',
])
raise AssertionError(
f'Expected at most {self!r} but got {actual!r} for path'
f' {path} ({details}).'
)
def is_special_fs(path: Path) -> bool:
"""
Check whether the given path truly is a special file system such as procfs
or sysfs.
"""
try:
if path == Path('/proc'):
return (path / 'version').read_text().startswith('Linux')
elif path == Path('/sys'):
return b'Linux' in (path / 'kernel' / 'notes').read_bytes()
except FileNotFoundError:
pass
return False
def is_empty_dir(path: Path) -> bool:
try:
next(path.iterdir())
return False
except (StopIteration, PermissionError):
return True
def _assert_permissions_in_directory(
directory: Path,
accessibility: Accessibility,
subdirs: dict[Path, Accessibility],
) -> None:
accessibility.assert_on(directory)
for file in directory.iterdir():
if is_special_fs(file):
msg = f'Got unexpected special filesystem at {file}.'
assert subdirs.pop(file) == Accessibility.SPECIAL, msg
elif not file.is_symlink() and file.is_dir():
subdir_access = subdirs.pop(file, accessibility)
if is_empty_dir(file):
# Whenever we got an empty directory, we check the permission
# constraints on the current directory (except if specified
# explicitly in subdirs) because for example if we're non-root
# (the constraints of the current directory are thus
# Accessibility.READABLE), we really have to make sure that
# empty directories are *never* writable.
subdir_access.assert_on(file)
else:
_assert_permissions_in_directory(file, subdir_access, subdirs)
else:
subdirs.pop(file, accessibility).assert_on(file)
def assert_permissions(subdirs: dict[str, Accessibility]) -> None:
"""
Recursively check whether the file system conforms to the accessibility
specification we specified via 'subdirs'.
"""
root = Path('/')
absolute_subdirs = {root / p: a for p, a in subdirs.items()}
_assert_permissions_in_directory(
root,
Accessibility.WRITABLE if os.getuid() == 0 else Accessibility.READABLE,
absolute_subdirs,
)
for file in absolute_subdirs.keys():
msg = f'Expected {file} to exist, but it was nowwhere to be found.'
raise AssertionError(msg)

View File

@ -0,0 +1,274 @@
import ../make-test-python.nix {
name = "systemd-confinement";
nodes.machine = { pkgs, lib, ... }: let
testLib = pkgs.python3Packages.buildPythonPackage {
name = "confinement-testlib";
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.cartesianProductOfSets {
user = [ "root" "dynamic-user" "static-user" ];
privateTmp = [ true false ];
});
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
'';
}
]);
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")
'';
}