detached 63269d4

fix(unit): drop PrivateTmp, ProtectHome, ProtectKernelModules and ProtectKernelLogs, which hide the loop mounts of container mode from Docker; test that the unit sets no option creating a mount namespace (GO-001)

commit
63269d4a7636cbbcb9a8aa168540b3b83e307efb
author
brice · · 2026-10-12 20:09 UTC
parent
3f47b6d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +126 -41
M README.md +33 -22
@@ -155,28 +155,39 @@ everything labelled as the runner's own.
155155
156156 **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables,
157157 loop mounts, the jailer, Docker), so the unit only takes away what it does not
158-use: `NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectClock`,
159-`ProtectKernelModules`, `ProtectKernelLogs`, `RestrictSUIDSGID`,
160-`LockPersonality`, `RestrictRealtime`, `SystemCallArchitectures=native`,
161-`RestrictAddressFamilies` (UNIX, INET, INET6, NETLINK, VSOCK).
162-Checked on a workstation (a user manager, no root): the micro-VM job test
163-(`TestVMRunsAJob`: user and network namespaces, Firecracker, TAP, vsock) passes
164-under every option above except `ProtectHome`, which hides the home directory
165-where that test's tree lives; with it the binary itself was run (`version`) and
166-`/home` is denied as intended. Not checked, because they need root on a real
167-host: the jailer, loop mounts, nftables in the host namespace, Docker. **Deliberately absent**, and why:
168-`Delegate=` (the runner manages no cgroup of its own: the jailer writes the
169-VMs' cgroups as root, Docker has its own), `ProtectSystem=`,
170-`ProtectControlGroups=`, `ProtectKernelTunables=` (the jailer writes cgroups, the
171-runner turns on `net.ipv4.ip_forward`), `PrivateDevices=` (`/dev/kvm`,
172-`/dev/net/tun`, `/dev/loop*`), `RestrictNamespaces=`, `PrivateNetwork=`,
173-`CapabilityBoundingSet=` and `SystemCallFilter=` (network namespaces and
174-devices, mounts, capabilities handed to the jailer and Docker),
175-`MemoryDenyWriteExecute=` (Firecracker maps guest memory writable and
176-executable), `UMask=` (the drivers create directories that the job's user must
177-enter, with explicit modes; the state directory is made 0700 by the runner). The boundary of a job is its micro-VM or container, not this unit;
178-the unit cannot be tested end to end without a rootful host, so the options that
179-would only break it silently are left out.
158+use, and only with options that **leave it in the host's mount namespace**:
159+`NoNewPrivileges`, `ProtectClock`, `RestrictSUIDSGID`, `LockPersonality`,
160+`RestrictRealtime`, `SystemCallArchitectures=native` and `RestrictAddressFamilies`
161+(UNIX, INET, INET6, NETLINK, VSOCK).
162+
163+**Deliberately absent**, and why:
164+
165+- **Every option that gives the unit a mount namespace of its own:** `PrivateTmp`,
166+ `ProtectHome`, `ProtectSystem`, `ProtectKernelModules`, `ProtectKernelLogs`,
167+ `ProtectKernelTunables`, `ProtectControlGroups`, `PrivateDevices`,
168+ `ReadOnlyPaths`, `ReadWritePaths`, `InaccessiblePaths`, `TemporaryFileSystem`,
169+ `BindPaths`. In container mode the runner loop-mounts the scratch disk of each
170+ job and the Docker daemon, which lives in the host's namespace, binds that
171+ directory into the container; a mount made inside the unit's namespace is
172+ invisible to it and the container would get an empty directory. (`ProtectKernelModules`
173+ and `ProtectKernelLogs` look harmless but are implemented with mounts too.) The
174+ micro-VM mode would tolerate them, but one unit serves both modes, and a second
175+ unit for VM-only hardening is not worth maintaining. A test in `internal/host`
176+ fails if the unit sets any of them.
177+- `Delegate=`: the runner manages no cgroup of its own (the jailer writes the
178+ VMs' cgroups as root, Docker has its own).
179+- `RestrictNamespaces=`, `PrivateNetwork=`, `CapabilityBoundingSet=`,
180+ `SystemCallFilter=`: network namespaces and devices (TAP, veth), mounts,
181+ `net.ipv4.ip_forward`, capabilities handed to the jailer and Docker.
182+- `MemoryDenyWriteExecute=`: Firecracker maps guest memory writable and executable.
183+- `UMask=`: the drivers create directories that the job's user must enter, with
184+ explicit modes; the state directory is made 0700 by the runner.
185+
186+The boundary of a job is its micro-VM or container, not this unit. Checked on a
187+workstation (a user manager, no root): the micro-VM job test (`TestVMRunsAJob`:
188+user and network namespaces, Firecracker, TAP, vsock) passes under the options
189+that remain. Not checked, because they need root on a real host: the jailer, loop
190+mounts, nftables in the host namespace, Docker.
180191
181192 ## The image
182193
@@ -155,28 +155,39 @@ everything labelled as the runner's own.
155 155
156 **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables,156 **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables,
157 loop mounts, the jailer, Docker), so the unit only takes away what it does not157 loop mounts, the jailer, Docker), so the unit only takes away what it does not
158-use: `NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectClock`,158+use, and only with options that **leave it in the host's mount namespace**:
159-`ProtectKernelModules`, `ProtectKernelLogs`, `RestrictSUIDSGID`,159+`NoNewPrivileges`, `ProtectClock`, `RestrictSUIDSGID`, `LockPersonality`,
160-`LockPersonality`, `RestrictRealtime`, `SystemCallArchitectures=native`,160+`RestrictRealtime`, `SystemCallArchitectures=native` and `RestrictAddressFamilies`
161-`RestrictAddressFamilies` (UNIX, INET, INET6, NETLINK, VSOCK).161+(UNIX, INET, INET6, NETLINK, VSOCK).
162-Checked on a workstation (a user manager, no root): the micro-VM job test162+
163-(`TestVMRunsAJob`: user and network namespaces, Firecracker, TAP, vsock) passes163+**Deliberately absent**, and why:
164-under every option above except `ProtectHome`, which hides the home directory164+
165-where that test's tree lives; with it the binary itself was run (`version`) and165+- **Every option that gives the unit a mount namespace of its own:** `PrivateTmp`,
166-`/home` is denied as intended. Not checked, because they need root on a real166+ `ProtectHome`, `ProtectSystem`, `ProtectKernelModules`, `ProtectKernelLogs`,
167-host: the jailer, loop mounts, nftables in the host namespace, Docker. **Deliberately absent**, and why:167+ `ProtectKernelTunables`, `ProtectControlGroups`, `PrivateDevices`,
168-`Delegate=` (the runner manages no cgroup of its own: the jailer writes the168+ `ReadOnlyPaths`, `ReadWritePaths`, `InaccessiblePaths`, `TemporaryFileSystem`,
169-VMs' cgroups as root, Docker has its own), `ProtectSystem=`,169+ `BindPaths`. In container mode the runner loop-mounts the scratch disk of each
170-`ProtectControlGroups=`, `ProtectKernelTunables=` (the jailer writes cgroups, the170+ job and the Docker daemon, which lives in the host's namespace, binds that
171-runner turns on `net.ipv4.ip_forward`), `PrivateDevices=` (`/dev/kvm`,171+ directory into the container; a mount made inside the unit's namespace is
172-`/dev/net/tun`, `/dev/loop*`), `RestrictNamespaces=`, `PrivateNetwork=`,172+ invisible to it and the container would get an empty directory. (`ProtectKernelModules`
173-`CapabilityBoundingSet=` and `SystemCallFilter=` (network namespaces and173+ and `ProtectKernelLogs` look harmless but are implemented with mounts too.) The
174-devices, mounts, capabilities handed to the jailer and Docker),174+ micro-VM mode would tolerate them, but one unit serves both modes, and a second
175-`MemoryDenyWriteExecute=` (Firecracker maps guest memory writable and175+ unit for VM-only hardening is not worth maintaining. A test in `internal/host`
176-executable), `UMask=` (the drivers create directories that the job's user must176+ fails if the unit sets any of them.
177-enter, with explicit modes; the state directory is made 0700 by the runner). The boundary of a job is its micro-VM or container, not this unit;177+- `Delegate=`: the runner manages no cgroup of its own (the jailer writes the
178-the unit cannot be tested end to end without a rootful host, so the options that178+ VMs' cgroups as root, Docker has its own).
179-would only break it silently are left out.179+- `RestrictNamespaces=`, `PrivateNetwork=`, `CapabilityBoundingSet=`,
180+ `SystemCallFilter=`: network namespaces and devices (TAP, veth), mounts,
181+ `net.ipv4.ip_forward`, capabilities handed to the jailer and Docker.
182+- `MemoryDenyWriteExecute=`: Firecracker maps guest memory writable and executable.
183+- `UMask=`: the drivers create directories that the job's user must enter, with
184+ explicit modes; the state directory is made 0700 by the runner.
185+
186+The boundary of a job is its micro-VM or container, not this unit. Checked on a
187+workstation (a user manager, no root): the micro-VM job test (`TestVMRunsAJob`:
188+user and network namespaces, Firecracker, TAP, vsock) passes under the options
189+that remain. Not checked, because they need root on a real host: the jailer, loop
190+mounts, nftables in the host namespace, Docker.
180 191
181 ## The image192 ## The image
182 193
M contrib/ermite-runner.service +25 -19
@@ -43,21 +43,16 @@ RuntimeDirectory=ermite-runner
4343 TasksMax=infinity
4444 LimitNOFILE=1048576
4545
46-# ---- sandboxing that keeps loop mounts, TAP, nftables, KVM and the jailer ----
46+# ---- sandboxing that leaves the unit in the host's mount namespace ----------
47+# Only options that do not give the unit a mount namespace of its own: in
48+# container mode the runner loop-mounts the scratch disk of a job, and the Docker
49+# daemon, which lives in the host's namespace, must see that mount to bind it
50+# into the container. See the end of the file.
4751 NoNewPrivileges=yes
48-# Nobody else's files, and no interactive logins' data.
49-ProtectHome=yes
50-# A private /tmp: the runner and the jailer do not share files with the host
51-# through it (the sandbox directories live in the state directory).
52-PrivateTmp=yes
53-# The runner does not set the clock, load modules or change kernel logging.
52+# The runner does not set the clock.
5453 ProtectClock=yes
55-ProtectKernelModules=yes
56-ProtectKernelLogs=yes
5754 # No setuid/setgid files created by the runner or its children.
5855 RestrictSUIDSGID=yes
59-# Kernel tunables stay read-only for the unit... except where the runner needs
60-# them: see the end of the file (net.ipv4.ip_forward, cgroups).
6156 LockPersonality=yes
6257 # The runner does not need real-time scheduling.
6358 RestrictRealtime=yes
@@ -74,27 +69,38 @@ WantedBy=multi-user.target
7469
7570 # ---- deliberately absent -----------------------------------------------------
7671 #
72+# PrivateTmp=, ProtectHome=, ProtectSystem=, ProtectKernelModules=,
73+# ProtectKernelLogs=, ProtectKernelTunables=, ProtectControlGroups=,
74+# PrivateDevices=, ReadOnlyPaths=, ReadWritePaths=, InaccessiblePaths=,
75+# TemporaryFileSystem=, BindPaths=
76+# Each of these puts the unit in a mount namespace of its own
77+# (systemd sets one up for ProtectKernelModules and
78+# ProtectKernelLogs too: it hides /usr/lib/modules and
79+# /proc/kmsg with mounts). In container mode the runner
80+# loop-mounts the scratch disk of a job below the state
81+# directory, and the Docker daemon, in the host's namespace,
82+# cannot see a mount made inside the unit's: the bind
83+# sources of the container would be empty directories. The
84+# micro-VM mode would tolerate them, but one unit serves both
85+# modes and a second unit for VM-only hardening is not worth
86+# it. test: internal/host TestUnitHasNoMountNamespaceOptions.
87+#
7788 # Delegate=yes The runner does not manage cgroups of its own. The jailer
7889 # creates the cgroups of the VMs below /sys/fs/cgroup itself
7990 # (it is root and writes there directly), and Docker has its
8091 # own. Delegation would hand a subtree to this unit that
8192 # nothing uses.
82-# ProtectSystem= The runner writes to the state directory, /run, /sys/fs/cgroup,
83-# /dev (mounts, TAP, loop devices), and Docker's data: a
84-# read-only /usr is possible but would be the only gain, and
85-# it breaks `ip`/`nft` helpers found by PATH on some distros.
86-# PrivateDevices= The runner needs /dev/kvm, /dev/net/tun, /dev/loop*.
87-# PrivateNetwork=, RestrictNamespaces=, ProtectKernelTunables=,
93+# PrivateNetwork=, RestrictNamespaces=,
8894 # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter=
8995 # The runner creates network namespaces and devices (TAP,
9096 # veth, bridges) and mounts file systems, enables
91-# net.ipv4.ip_forward, hands capabilities to the jailer and
97+# net.ipv4.ip_forward (the jailer also writes cgroups, /dev/kvm, /dev/net/tun and
98+# /dev/loop* are needed), hands capabilities to the jailer and
9299 # to Docker, and starts Firecracker (which maps guest memory
93100 # writable and executable). Each of these would break a path
94101 # that cannot be tested without a rootful host; the
95102 # boundary of a job is the micro-VM or the container, not
96103 # this unit.
97-# ProtectControlGroups= The jailer writes the cgroups of the VMs.
98104 # UMask=0077 The state directory is made 0700 by the runner itself, and the
99105 # drivers create directories that the job's user must be able
100106 # to enter (the scratch disk's work and tmp directories are
@@ -43,21 +43,16 @@ RuntimeDirectory=ermite-runner
43 TasksMax=infinity43 TasksMax=infinity
44 LimitNOFILE=104857644 LimitNOFILE=1048576
45 45
46-# ---- sandboxing that keeps loop mounts, TAP, nftables, KVM and the jailer ----46+# ---- sandboxing that leaves the unit in the host's mount namespace ----------
47+# Only options that do not give the unit a mount namespace of its own: in
48+# container mode the runner loop-mounts the scratch disk of a job, and the Docker
49+# daemon, which lives in the host's namespace, must see that mount to bind it
50+# into the container. See the end of the file.
47 NoNewPrivileges=yes51 NoNewPrivileges=yes
48-# Nobody else's files, and no interactive logins' data.52+# The runner does not set the clock.
49-ProtectHome=yes
50-# A private /tmp: the runner and the jailer do not share files with the host
51-# through it (the sandbox directories live in the state directory).
52-PrivateTmp=yes
53-# The runner does not set the clock, load modules or change kernel logging.
54 ProtectClock=yes53 ProtectClock=yes
55-ProtectKernelModules=yes
56-ProtectKernelLogs=yes
57 # No setuid/setgid files created by the runner or its children.54 # No setuid/setgid files created by the runner or its children.
58 RestrictSUIDSGID=yes55 RestrictSUIDSGID=yes
59-# Kernel tunables stay read-only for the unit... except where the runner needs
60-# them: see the end of the file (net.ipv4.ip_forward, cgroups).
61 LockPersonality=yes56 LockPersonality=yes
62 # The runner does not need real-time scheduling.57 # The runner does not need real-time scheduling.
63 RestrictRealtime=yes58 RestrictRealtime=yes
@@ -74,27 +69,38 @@ WantedBy=multi-user.target
74 69
75 # ---- deliberately absent -----------------------------------------------------70 # ---- deliberately absent -----------------------------------------------------
76 #71 #
72+# PrivateTmp=, ProtectHome=, ProtectSystem=, ProtectKernelModules=,
73+# ProtectKernelLogs=, ProtectKernelTunables=, ProtectControlGroups=,
74+# PrivateDevices=, ReadOnlyPaths=, ReadWritePaths=, InaccessiblePaths=,
75+# TemporaryFileSystem=, BindPaths=
76+# Each of these puts the unit in a mount namespace of its own
77+# (systemd sets one up for ProtectKernelModules and
78+# ProtectKernelLogs too: it hides /usr/lib/modules and
79+# /proc/kmsg with mounts). In container mode the runner
80+# loop-mounts the scratch disk of a job below the state
81+# directory, and the Docker daemon, in the host's namespace,
82+# cannot see a mount made inside the unit's: the bind
83+# sources of the container would be empty directories. The
84+# micro-VM mode would tolerate them, but one unit serves both
85+# modes and a second unit for VM-only hardening is not worth
86+# it. test: internal/host TestUnitHasNoMountNamespaceOptions.
87+#
77 # Delegate=yes The runner does not manage cgroups of its own. The jailer88 # Delegate=yes The runner does not manage cgroups of its own. The jailer
78 # creates the cgroups of the VMs below /sys/fs/cgroup itself89 # creates the cgroups of the VMs below /sys/fs/cgroup itself
79 # (it is root and writes there directly), and Docker has its90 # (it is root and writes there directly), and Docker has its
80 # own. Delegation would hand a subtree to this unit that91 # own. Delegation would hand a subtree to this unit that
81 # nothing uses.92 # nothing uses.
82-# ProtectSystem= The runner writes to the state directory, /run, /sys/fs/cgroup,93+# PrivateNetwork=, RestrictNamespaces=,
83-# /dev (mounts, TAP, loop devices), and Docker's data: a
84-# read-only /usr is possible but would be the only gain, and
85-# it breaks `ip`/`nft` helpers found by PATH on some distros.
86-# PrivateDevices= The runner needs /dev/kvm, /dev/net/tun, /dev/loop*.
87-# PrivateNetwork=, RestrictNamespaces=, ProtectKernelTunables=,
88 # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter=94 # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter=
89 # The runner creates network namespaces and devices (TAP,95 # The runner creates network namespaces and devices (TAP,
90 # veth, bridges) and mounts file systems, enables96 # veth, bridges) and mounts file systems, enables
91-# net.ipv4.ip_forward, hands capabilities to the jailer and97+# net.ipv4.ip_forward (the jailer also writes cgroups, /dev/kvm, /dev/net/tun and
98+# /dev/loop* are needed), hands capabilities to the jailer and
92 # to Docker, and starts Firecracker (which maps guest memory99 # to Docker, and starts Firecracker (which maps guest memory
93 # writable and executable). Each of these would break a path100 # writable and executable). Each of these would break a path
94 # that cannot be tested without a rootful host; the101 # that cannot be tested without a rootful host; the
95 # boundary of a job is the micro-VM or the container, not102 # boundary of a job is the micro-VM or the container, not
96 # this unit.103 # this unit.
97-# ProtectControlGroups= The jailer writes the cgroups of the VMs.
98 # UMask=0077 The state directory is made 0700 by the runner itself, and the104 # UMask=0077 The state directory is made 0700 by the runner itself, and the
99 # drivers create directories that the job's user must be able105 # drivers create directories that the job's user must be able
100 # to enter (the scratch disk's work and tmp directories are106 # to enter (the scratch disk's work and tmp directories are
M internal/host/runner_test.go +68 -0
@@ -1246,3 +1246,71 @@ func TestSaveStateMakesAnExistingDirectoryPrivate(t *testing.T) {
12461246 t.Fatalf("%v", fi.Mode())
12471247 }
12481248 }
1249+
1250+// mountNamespaceOptions are the [Service] options that give a unit a mount
1251+// namespace of its own. The unit must set none of them: in container mode the
1252+// runner loop-mounts the scratch disk of a job; the Docker daemon, in the host's
1253+// namespace, binds it into the container, and a mount made inside a private mount
1254+// namespace of the unit is invisible to it (GO-001). Besides the options that say
1255+// so (PrivateTmp, ProtectHome, ProtectSystem...), systemd builds a mount
1256+// namespace for ProtectKernelModules and ProtectKernelLogs too.
1257+var mountNamespaceOptions = []string{
1258+ "PrivateTmp", "PrivateDevices", "PrivateMounts", "PrivateUsers", "ProtectHome", "ProtectSystem",
1259+ "ProtectKernelModules", "ProtectKernelLogs", "ProtectKernelTunables", "ProtectControlGroups",
1260+ "ProtectProc", "ProcSubset", "ProtectHostname", "ReadOnlyPaths", "ReadWritePaths", "InaccessiblePaths",
1261+ "ExecPaths", "NoExecPaths", "TemporaryFileSystem", "BindPaths", "BindReadOnlyPaths", "MountFlags",
1262+ "MountAPIVFS", "RootDirectory", "RootImage", "RootEphemeral", "BindLogSockets", "LogNamespace",
1263+ "PrivateIPC", "PrivatePIDs", "MountImages", "ExtensionImages", "ExtensionDirectories",
1264+}
1265+
1266+// unitMountNamespaceOptions returns the lines of the [Service] section of a unit
1267+// that set one of mountNamespaceOptions. Comments, which name the options on
1268+// purpose, and other sections are ignored.
1269+func unitMountNamespaceOptions(unit string) []string {
1270+ var found []string
1271+ inService := false
1272+ for _, line := range strings.Split(unit, "\n") {
1273+ line = strings.TrimSpace(line)
1274+ if strings.HasPrefix(line, "[") {
1275+ inService = line == "[Service]"
1276+ continue
1277+ }
1278+ if !inService || line == "" || strings.HasPrefix(line, "#") {
1279+ continue
1280+ }
1281+ key, _, _ := strings.Cut(line, "=")
1282+ for _, f := range mountNamespaceOptions {
1283+ if strings.EqualFold(strings.TrimSpace(key), f) {
1284+ found = append(found, line)
1285+ }
1286+ }
1287+ }
1288+ return found
1289+}
1290+
1291+func TestUnitHasNoMountNamespaceOptions(t *testing.T) {
1292+ unit, err := os.ReadFile("../../contrib/ermite-runner.service")
1293+ if err != nil {
1294+ t.Skip(err)
1295+ }
1296+ if found := unitMountNamespaceOptions(string(unit)); len(found) > 0 {
1297+ t.Fatalf("the unit sets options that give it a mount namespace of its own, which hides the loop mounts from Docker: %q", found)
1298+ }
1299+ // The hardening that stays must still be there.
1300+ for _, want := range []string{"NoNewPrivileges=yes", "RestrictSUIDSGID=yes", "ProtectClock=yes", "RestrictAddressFamilies="} {
1301+ if !strings.Contains(string(unit), "\n"+want) {
1302+ t.Errorf("the unit lost %s", want)
1303+ }
1304+ }
1305+}
1306+
1307+// The check sees the options in [Service] (also with spaces and another case),
1308+// and ignores comments and other sections.
1309+func TestUnitMountNamespaceCheckSeesOptions(t *testing.T) {
1310+ unit := "[Unit]\nPrivateTmp=yes\n[Service]\nPrivateTmp=yes\n protecthome = read-only\nProtectKernelModules=yes\n# ProtectSystem=strict\nNoNewPrivileges=yes\n[Install]\nProtectSystem=yes\n"
1311+ got := unitMountNamespaceOptions(unit)
1312+ want := []string{"PrivateTmp=yes", "protecthome = read-only", "ProtectKernelModules=yes"}
1313+ if strings.Join(got, "|") != strings.Join(want, "|") {
1314+ t.Fatalf("%q", got)
1315+ }
1316+}
@@ -1246,3 +1246,71 @@ func TestSaveStateMakesAnExistingDirectoryPrivate(t *testing.T) {
1246 t.Fatalf("%v", fi.Mode())1246 t.Fatalf("%v", fi.Mode())
1247 }1247 }
1248 }1248 }
1249+
1250+// mountNamespaceOptions are the [Service] options that give a unit a mount
1251+// namespace of its own. The unit must set none of them: in container mode the
1252+// runner loop-mounts the scratch disk of a job; the Docker daemon, in the host's
1253+// namespace, binds it into the container, and a mount made inside a private mount
1254+// namespace of the unit is invisible to it (GO-001). Besides the options that say
1255+// so (PrivateTmp, ProtectHome, ProtectSystem...), systemd builds a mount
1256+// namespace for ProtectKernelModules and ProtectKernelLogs too.
1257+var mountNamespaceOptions = []string{
1258+ "PrivateTmp", "PrivateDevices", "PrivateMounts", "PrivateUsers", "ProtectHome", "ProtectSystem",
1259+ "ProtectKernelModules", "ProtectKernelLogs", "ProtectKernelTunables", "ProtectControlGroups",
1260+ "ProtectProc", "ProcSubset", "ProtectHostname", "ReadOnlyPaths", "ReadWritePaths", "InaccessiblePaths",
1261+ "ExecPaths", "NoExecPaths", "TemporaryFileSystem", "BindPaths", "BindReadOnlyPaths", "MountFlags",
1262+ "MountAPIVFS", "RootDirectory", "RootImage", "RootEphemeral", "BindLogSockets", "LogNamespace",
1263+ "PrivateIPC", "PrivatePIDs", "MountImages", "ExtensionImages", "ExtensionDirectories",
1264+}
1265+
1266+// unitMountNamespaceOptions returns the lines of the [Service] section of a unit
1267+// that set one of mountNamespaceOptions. Comments, which name the options on
1268+// purpose, and other sections are ignored.
1269+func unitMountNamespaceOptions(unit string) []string {
1270+ var found []string
1271+ inService := false
1272+ for _, line := range strings.Split(unit, "\n") {
1273+ line = strings.TrimSpace(line)
1274+ if strings.HasPrefix(line, "[") {
1275+ inService = line == "[Service]"
1276+ continue
1277+ }
1278+ if !inService || line == "" || strings.HasPrefix(line, "#") {
1279+ continue
1280+ }
1281+ key, _, _ := strings.Cut(line, "=")
1282+ for _, f := range mountNamespaceOptions {
1283+ if strings.EqualFold(strings.TrimSpace(key), f) {
1284+ found = append(found, line)
1285+ }
1286+ }
1287+ }
1288+ return found
1289+}
1290+
1291+func TestUnitHasNoMountNamespaceOptions(t *testing.T) {
1292+ unit, err := os.ReadFile("../../contrib/ermite-runner.service")
1293+ if err != nil {
1294+ t.Skip(err)
1295+ }
1296+ if found := unitMountNamespaceOptions(string(unit)); len(found) > 0 {
1297+ t.Fatalf("the unit sets options that give it a mount namespace of its own, which hides the loop mounts from Docker: %q", found)
1298+ }
1299+ // The hardening that stays must still be there.
1300+ for _, want := range []string{"NoNewPrivileges=yes", "RestrictSUIDSGID=yes", "ProtectClock=yes", "RestrictAddressFamilies="} {
1301+ if !strings.Contains(string(unit), "\n"+want) {
1302+ t.Errorf("the unit lost %s", want)
1303+ }
1304+ }
1305+}
1306+
1307+// The check sees the options in [Service] (also with spaces and another case),
1308+// and ignores comments and other sections.
1309+func TestUnitMountNamespaceCheckSeesOptions(t *testing.T) {
1310+ unit := "[Unit]\nPrivateTmp=yes\n[Service]\nPrivateTmp=yes\n protecthome = read-only\nProtectKernelModules=yes\n# ProtectSystem=strict\nNoNewPrivileges=yes\n[Install]\nProtectSystem=yes\n"
1311+ got := unitMountNamespaceOptions(unit)
1312+ want := []string{"PrivateTmp=yes", "protecthome = read-only", "ProtectKernelModules=yes"}
1313+ if strings.Join(got, "|") != strings.Join(want, "|") {
1314+ t.Fatalf("%q", got)
1315+ }
1316+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close