···
name = "systemd-confinement";
nodes.machine = { pkgs, lib, ... }: let
5
-
testServer = pkgs.writeScript "testserver.sh" ''
6
-
#!${pkgs.runtimeShell}
7
-
export PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.findutils ]}
8
-
${lib.escapeShellArg pkgs.runtimeShell} 2>&1
9
-
echo "exit-status:$?"
5
+
testLib = pkgs.python3Packages.buildPythonPackage {
6
+
name = "confinement-testlib";
9
+
from setuptools import setup
10
+
setup(name='confinement-testlib', py_modules=["checkperms"])
12
+
cp ${./checkperms.py} checkperms.py
12
-
testClient = pkgs.writeScriptBin "chroot-exec" ''
13
-
#!${pkgs.runtimeShell} -e
14
-
output="$(echo "$@" | nc -NU "/run/test$(< /teststep).sock")"
15
-
ret="$(echo "$output" | sed -nre '$s/^exit-status:([0-9]+)$/\1/p')"
16
-
echo "$output" | head -n -1
16
+
mkTest = name: testScript: pkgs.writers.writePython3 "${name}.py" {
17
+
libraries = [ pkgs.python3Packages.pytest testLib ];
19
+
# This runs our test script by using pytest's assertion rewriting, so
20
+
# that whenever we use "assert <something>", the actual values are
21
+
# printed rather than getting a generic AssertionError or the need to
22
+
# pass an explicit assertion error message.
24
+
from pathlib import Path
25
+
from _pytest.assertion.rewrite import rewrite_asserts
27
+
script = Path('${pkgs.writeText "${name}-main.py" ''
28
+
import errno, os, pytest, signal
29
+
from subprocess import run
30
+
from checkperms import Accessibility, assert_permissions
34
+
filename = str(script)
35
+
source = script.read_bytes()
37
+
tree = ast.parse(source, filename=filename)
38
+
rewrite_asserts(tree, source, filename)
39
+
exec(compile(tree, filename, 'exec', dont_inherit=True))
···
serviceName ? "test${toString num}",
26
-
systemd.sockets.${serviceName} = {
27
-
description = "Socket for Test Service ${toString num}";
28
-
wantedBy = [ "sockets.target" ];
29
-
socketConfig.ListenStream = "/run/test${toString num}.sock";
30
-
socketConfig.Accept = true;
49
+
systemd.packages = lib.optional (rawUnit != null) (pkgs.writeTextFile {
51
+
destination = "/etc/systemd/system/${serviceName}.service";
33
-
systemd.services."${serviceName}@" = {
34
-
description = "Confined Test Service ${toString num}";
55
+
systemd.services.${serviceName} = {
56
+
inherit description;
57
+
requiredBy = [ "multi-user.target" ];
confinement = (config.confinement or {}) // { enable = true; };
serviceConfig = (config.serviceConfig or {}) // {
37
-
ExecStart = testServer;
38
-
StandardInput = "socket";
60
+
ExecStart = mkTest serviceName testScript;
} // removeAttrs config [ "confinement" "serviceConfig" ];
42
-
__testSteps = lib.mkOrder num ''
43
-
with subtest('${lib.escape ["'" "\\"] description}'):
44
-
machine.succeed("echo ${toString num} > /teststep")
45
-
${lib.replaceStrings ["\n"] ["\n "] testScript}
···
{ description = "chroot-only confinement";
config.confinement.mode = "chroot-only";
54
-
# chroot-exec starts a socket-activated service,
55
-
# but, upon starting, a systemd system service
56
-
# calls setup_namespace() which calls base_filesystem_create()
57
-
# which creates some usual top level directories.
58
-
# In chroot-only mode, without additional BindPaths= or the like,
59
-
# they must be empty and thus removable by rmdir.
60
-
paths = machine.succeed('chroot-exec rmdir /dev /etc /proc /root /sys /usr /var "&&" ls -Am /').strip()
61
-
assert_eq(paths, "bin, nix, run")
62
-
uid = machine.succeed('chroot-exec id -u').strip()
64
-
machine.succeed("chroot-exec chown 65534 /bin")
71
+
assert_permissions({
72
+
'bin': Accessibility.WRITABLE,
73
+
'nix': Accessibility.WRITABLE,
74
+
'run': Accessibility.WRITABLE,
77
+
assert os.getuid() == 0
78
+
os.chown('/bin', 65534, 0)
{ description = "full confinement with APIVFS";
69
-
machine.succeed('chroot-exec rmdir /etc')
70
-
machine.fail("chroot-exec chown 65534 /bin")
71
-
assert_eq(machine.succeed('chroot-exec id -u').strip(), "0")
72
-
machine.succeed("chroot-exec chown 0 /bin")
83
+
Path('/etc').rmdir()
85
+
assert_permissions({
86
+
'bin': Accessibility.WRITABLE,
87
+
'nix': Accessibility.WRITABLE,
88
+
'tmp': Accessibility.WRITABLE,
89
+
'run': Accessibility.WRITABLE,
91
+
'proc': Accessibility.SPECIAL,
92
+
'sys': Accessibility.SPECIAL,
93
+
'dev': Accessibility.WRITABLE,
96
+
bin_gid = Path('/bin').stat().st_gid
97
+
with pytest.raises(OSError) as excinfo:
98
+
os.chown('/bin', 65534, bin_gid)
99
+
assert excinfo.value.errno == errno.EINVAL
101
+
assert os.getuid() == 0
102
+
os.chown('/bin', 0, 0)
{ description = "check existence of bind-mounted /etc";
config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
78
-
passwd = machine.succeed('chroot-exec cat /etc/passwd').strip()
79
-
assert len(passwd) > 0, "/etc/passwd must not be empty"
108
+
assert Path('/etc/passwd').read_text()
{ description = "check if User/Group really runs as non-root";
config.serviceConfig.User = "chroot-testuser";
config.serviceConfig.Group = "chroot-testgroup";
86
-
machine.succeed("chroot-exec ls -l /dev")
87
-
uid = machine.succeed('chroot-exec id -u').strip()
88
-
assert uid != "0", "UID of chroot-testuser shouldn't be 0"
89
-
machine.fail("chroot-exec touch /bin/test")
115
+
assert list(Path('/dev').iterdir())
117
+
assert os.getuid() != 0
118
+
assert os.getgid() != 0
120
+
with pytest.raises(PermissionError):
121
+
Path('/bin/test').touch()
{ description = "check if DynamicUser is working in full-apivfs mode";
config.confinement.mode = "full-apivfs";
config.serviceConfig.DynamicUser = true;
96
-
machine.succeed("chroot-exec ls -l /dev")
97
-
paths = machine.succeed('chroot-exec find / -path /dev/"\\*" -prune -o -path /nix/"\\*" -prune -o -path /proc/"\\*" -prune -o -path /sys/"\\*" -prune -o -print || test $? = 1')
99
-
'\n'.join(sorted(paths.split('\n'))),
111
-
/run/host/.os-release-stage
112
-
/run/host/.os-release-stage/os-release
113
-
/run/host/os-release
115
-
/run/systemd/incoming
121
-
find: '/root': Permission denied
122
-
find: '/run/systemd/incoming': Permission denied
125
-
uid = machine.succeed('chroot-exec id -u').strip()
126
-
assert uid != "0", "UID of a DynamicUser shouldn't be 0"
127
-
machine.fail("chroot-exec touch /bin/test")
128
-
# DynamicUser=true implies ProtectSystem=strict
129
-
machine.fail("chroot-exec touch /etc/test")
128
+
assert_permissions({
129
+
'bin': Accessibility.READABLE,
130
+
'nix': Accessibility.READABLE,
131
+
'tmp': Accessibility.WRITABLE,
132
+
'run': Accessibility.STICKY,
134
+
'proc': Accessibility.SPECIAL,
135
+
'sys': Accessibility.SPECIAL,
137
+
'dev': Accessibility.SPECIAL,
138
+
'dev/shm': Accessibility.STICKY,
139
+
'dev/mqueue': Accessibility.STICKY,
141
+
'var': Accessibility.READABLE,
142
+
'var/tmp': Accessibility.WRITABLE,
145
+
assert os.getuid() != 0
146
+
assert os.getgid() != 0
148
+
with pytest.raises(OSError) as excinfo:
149
+
Path('/bin/test').touch()
150
+
assert excinfo.value.errno == errno.EROFS
152
+
with pytest.raises(OSError) as excinfo:
153
+
Path('/etc/test').touch()
154
+
assert excinfo.value.errno == errno.EROFS
{ description = "check if DynamicUser and PrivateTmp=false are working in full-apivfs mode";
···
config.serviceConfig.DynamicUser = true;
config.serviceConfig.PrivateTmp = false;
137
-
machine.succeed("chroot-exec ls -l /dev")
138
-
paths = machine.succeed('chroot-exec find / -path /dev/"\\*" -prune -o -path /nix/"\\*" -prune -o -path /proc/"\\*" -prune -o -path /sys/"\\*" -prune -o -print || test $? = 1')
140
-
'\n'.join(sorted(paths.split('\n'))),
152
-
/run/host/.os-release-stage
153
-
/run/host/.os-release-stage/os-release
154
-
/run/host/os-release
156
-
/run/systemd/incoming
160
-
find: '/root': Permission denied
161
-
find: '/run/systemd/incoming': Permission denied
164
-
uid = machine.succeed('chroot-exec id -u').strip()
165
-
assert uid != "0", "UID of a DynamicUser shouldn't be 0"
166
-
machine.fail("chroot-exec touch /bin/test")
167
-
# DynamicUser=true implies ProtectSystem=strict
168
-
machine.fail("chroot-exec touch /etc/test")
162
+
assert_permissions({
163
+
'bin': Accessibility.READABLE,
164
+
'nix': Accessibility.READABLE,
165
+
'run': Accessibility.STICKY,
167
+
'proc': Accessibility.SPECIAL,
168
+
'sys': Accessibility.SPECIAL,
170
+
'dev': Accessibility.SPECIAL,
171
+
'dev/shm': Accessibility.STICKY,
172
+
'dev/mqueue': Accessibility.STICKY,
175
+
assert os.getuid() != 0
176
+
assert os.getgid() != 0
178
+
with pytest.raises(OSError) as excinfo:
179
+
Path('/bin/test').touch()
180
+
assert excinfo.value.errno == errno.EROFS
182
+
with pytest.raises(OSError) as excinfo:
183
+
Path('/etc/test').touch()
184
+
assert excinfo.value.errno == errno.EROFS
{ description = "check if DynamicUser is working in chroot-only mode";
config.confinement.mode = "chroot-only";
config.serviceConfig.DynamicUser = true;
175
-
paths = machine.succeed('chroot-exec find / -path /nix/"\\*" -prune -o -print || test $? = 1')
177
-
'\n'.join(sorted(paths.split('\n'))),
189
-
/run/systemd/incoming
193
-
find: '/root': Permission denied
194
-
find: '/run/systemd/incoming': Permission denied
197
-
uid = machine.succeed('chroot-exec id -u').strip()
198
-
assert uid != "0", "UID of a DynamicUser shouldn't be 0"
199
-
machine.fail("chroot-exec touch /bin/test")
191
+
assert_permissions({
192
+
'bin': Accessibility.READABLE,
193
+
'nix': Accessibility.READABLE,
194
+
'run': Accessibility.READABLE,
197
+
assert os.getuid() != 0
198
+
assert os.getgid() != 0
200
+
with pytest.raises(OSError) as excinfo:
201
+
Path('/bin/test').touch()
202
+
assert excinfo.value.errno == errno.EROFS
{ description = "check if DynamicUser and PrivateTmp=true are working in chroot-only mode";
···
config.serviceConfig.DynamicUser = true;
config.serviceConfig.PrivateTmp = true;
207
-
paths = machine.succeed('chroot-exec find / -path /nix/"\\*" -prune -o -print || test $? = 1')
209
-
'\n'.join(sorted(paths.split('\n'))),
221
-
/run/systemd/incoming
227
-
find: '/root': Permission denied
228
-
find: '/run/systemd/incoming': Permission denied
231
-
uid = machine.succeed('chroot-exec id -u').strip()
232
-
assert uid != "0", "UID of a DynamicUser shouldn't be 0"
233
-
machine.fail("chroot-exec touch /bin/test")
210
+
assert_permissions({
211
+
'bin': Accessibility.READABLE,
212
+
'nix': Accessibility.READABLE,
213
+
'run': Accessibility.READABLE,
214
+
'tmp': Accessibility.WRITABLE,
216
+
'var': Accessibility.READABLE,
217
+
'var/tmp': Accessibility.WRITABLE,
220
+
assert os.getuid() != 0
221
+
assert os.getgid() != 0
223
+
with pytest.raises(OSError) as excinfo:
224
+
Path('/bin/test').touch()
225
+
assert excinfo.value.errno == errno.EROFS
symlink = pkgs.runCommand "symlink" {
238
-
target = pkgs.writeText "symlink-target" "got me\n";
230
+
target = pkgs.writeText "symlink-target" "got me";
} "ln -s \"$target\" \"$out\"";
description = "check if symlinks are properly bind-mounted";
config.confinement.packages = lib.singleton symlink;
244
-
machine.succeed("chroot-exec rmdir /etc")
245
-
text = machine.succeed('chroot-exec cat ${symlink}').strip()
246
-
assert_eq(text, "got me")
236
+
Path('/etc').rmdir()
237
+
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";
245
+
# We restart on purpose here since we want to check whether the state
246
+
# directory actually persists.
247
+
config.serviceConfig.Restart = "on-failure";
248
+
config.serviceConfig.RestartMode = "direct";
254
-
machine.succeed("chroot-exec touch /tmp/canary")
255
-
machine.succeed('chroot-exec "echo works > /var/lib/testme/foo"')
256
-
machine.succeed('test "$(< /var/lib/testme/foo)" = works')
257
-
machine.succeed("test ! -e /tmp/canary")
251
+
assert not Path('/tmp/canary').exists()
252
+
Path('/tmp/canary').touch()
254
+
if (foo := Path('/var/lib/testme/foo')).exists():
255
+
assert Path('/var/lib/testme/foo').read_text() == 'works'
257
+
Path('/var/lib/testme/foo').write_text('works')
258
+
print('<4>Exiting with failure to check persistence on restart.')
259
+
raise SystemExit(1)
{ description = "check if /bin/sh works";
263
-
"chroot-exec test -e /bin/sh",
264
-
'test "$(chroot-exec \'/bin/sh -c "echo bar"\')" = bar',
264
+
assert Path('/bin/sh').exists()
267
+
['/bin/sh', '-c', 'echo -n bar'],
268
+
capture_output=True,
271
+
assert result.stdout == b'bar'
{ description = "check if suppressing /bin/sh works";
config.confinement.binSh = null;
272
-
'chroot-exec test ! -e /bin/sh',
273
-
'test "$(chroot-exec \'/bin/sh -c "echo foo"\')" != foo',
277
+
assert not Path('/bin/sh').exists()
278
+
with pytest.raises(FileNotFoundError):
279
+
run(['/bin/sh', '-c', 'echo foo'])
{ description = "check if we can set /bin/sh to something different";
config.confinement.binSh = "${pkgs.hello}/bin/hello";
280
-
machine.succeed("chroot-exec test -e /bin/sh")
281
-
machine.succeed('test "$(chroot-exec /bin/sh -g foo)" = foo')
285
+
assert Path('/bin/sh').exists()
287
+
['/bin/sh', '-g', 'foo'],
288
+
capture_output=True,
291
+
assert result.stdout == b'foo\n'
{ description = "check if only Exec* dependencies are included";
285
-
config.environment.FOOBAR = pkgs.writeText "foobar" "eek\n";
295
+
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
287
-
machine.succeed('test "$(chroot-exec \'cat "$FOOBAR"\')" != eek')
297
+
with pytest.raises(FileNotFoundError):
298
+
Path(os.environ['FOOBAR']).read_text()
290
-
{ description = "check if all unit dependencies are included";
291
-
config.environment.FOOBAR = pkgs.writeText "foobar" "eek\n";
301
+
{ description = "check if fullUnit includes all dependencies";
302
+
config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
config.confinement.fullUnit = true;
294
-
machine.succeed('test "$(chroot-exec \'cat "$FOOBAR"\')" = eek')
305
+
assert Path(os.environ['FOOBAR']).read_text() == 'eek'
{ description = "check if shipped unit file still works";
298
-
serviceName = "shipped-unitfile";
config.confinement.mode = "chroot-only";
312
+
SystemCallFilter=~kill
313
+
SystemCallErrorNumber=ELOOP
302
-
'chroot-exec \'kill -9 $$ 2>&1 || :\' | '
303
-
'grep -q "Too many levels of symbolic links"'
316
+
with pytest.raises(OSError) as excinfo:
317
+
os.kill(os.getpid(), signal.SIGKILL)
318
+
assert excinfo.value.errno == errno.ELOOP
309
-
options.__testSteps = lib.mkOption {
310
-
type = lib.types.lines;
311
-
description = "All of the test steps combined as a single script.";
314
-
config.environment.systemPackages = lib.singleton testClient;
315
-
config.systemd.packages = lib.singleton (pkgs.writeTextFile {
316
-
name = "shipped-unitfile";
317
-
destination = "/etc/systemd/system/shipped-unitfile@.service";
320
-
SystemCallFilter=~kill
321
-
SystemCallErrorNumber=ELOOP
config.users.groups.chroot-testgroup = {};
config.users.users.chroot-testuser = {
···
333
-
testScript = { nodes, ... }: ''
334
-
from textwrap import dedent
337
-
def assert_eq(got, expected):
338
-
if got != expected:
339
-
diff = difflib.unified_diff(got.splitlines(keepends=True), expected.splitlines(keepends=True))
340
-
print("".join(diff))
341
-
assert got == expected, f"{got} != {expected}"
machine.wait_for_unit("multi-user.target")
344
-
'' + nodes.machine.__testSteps;