summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/security/systemd-chroot.nix160
-rw-r--r--nixos/modules/system/boot/systemd-lib.nix9
-rw-r--r--nixos/tests/all-tests.nix1
-rw-r--r--nixos/tests/systemd-chroot.nix129
5 files changed, 295 insertions, 5 deletions
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 2c7b15e65c49..768bc40d1796 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -170,6 +170,7 @@
./security/rtkit.nix
./security/wrappers/default.nix
./security/sudo.nix
+ ./security/systemd-chroot.nix
./services/admin/oxidized.nix
./services/admin/salt/master.nix
./services/admin/salt/minion.nix
diff --git a/nixos/modules/security/systemd-chroot.nix b/nixos/modules/security/systemd-chroot.nix
new file mode 100644
index 000000000000..befe2d3418c9
--- /dev/null
+++ b/nixos/modules/security/systemd-chroot.nix
@@ -0,0 +1,160 @@
+{ config, pkgs, lib, ... }:
+
+let
+ inherit (lib) types;
+ inherit (import ../system/boot/systemd-lib.nix {
+ inherit config pkgs lib;
+ }) mkPathSafeName;
+in {
+ options.systemd.services = lib.mkOption {
+ type = types.attrsOf (types.submodule ({ name, config, ... }: {
+ options.chroot.enable = lib.mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ If set, all the required runtime store paths for this service are
+ bind-mounted into a <literal>tmpfs</literal>-based <citerefentry>
+ <refentrytitle>chroot</refentrytitle>
+ <manvolnum>2</manvolnum>
+ </citerefentry>.
+ '';
+ };
+
+ options.chroot.packages = lib.mkOption {
+ type = types.listOf (types.either types.str types.package);
+ default = [];
+ description = let
+ mkScOption = optName: "<option>serviceConfig.${optName}</option>";
+ in ''
+ Additional packages or strings with context to add to the closure of
+ the chroot. By default, this includes all the packages from the
+ ${lib.concatMapStringsSep ", " mkScOption [
+ "ExecReload" "ExecStartPost" "ExecStartPre" "ExecStop"
+ "ExecStopPost"
+ ]} and ${mkScOption "ExecStart"} options.
+
+ <note><para><emphasis role="strong">Only</emphasis> the latter
+ (${mkScOption "ExecStart"}) will be used if
+ ${mkScOption "RootDirectoryStartOnly"} is enabled.</para></note>
+
+ <note><para>Also, the store paths listed in <option>path</option> are
+ <emphasis role="strong">not</emphasis> included in the closure as
+ well as paths from other options except those listed
+ above.</para></note>
+ '';
+ };
+
+ options.chroot.withBinSh = lib.mkOption {
+ type = types.bool;
+ default = true;
+ description = ''
+ Whether to symlink <command>dash</command> as
+ <filename>/bin/sh</filename> to the chroot.
+
+ This is useful for some applications, which for example use the
+ <citerefentry>
+ <refentrytitle>system</refentrytitle>
+ <manvolnum>3</manvolnum>
+ </citerefentry> library function to execute commands.
+ '';
+ };
+
+ options.chroot.confinement = lib.mkOption {
+ type = types.enum [ "full-apivfs" "chroot-only" ];
+ default = "full-apivfs";
+ description = ''
+ The value <literal>full-apivfs</literal> (the default) sets up
+ private <filename class="directory">/dev</filename>, <filename
+ class="directory">/proc</filename>, <filename
+ class="directory">/sys</filename> and <filename
+ class="directory">/tmp</filename> file systems in a separate user
+ name space.
+
+ If this is set to <literal>chroot-only</literal>, only the file
+ system name space is set up along with the call to <citerefentry>
+ <refentrytitle>chroot</refentrytitle>
+ <manvolnum>2</manvolnum>
+ </citerefentry>.
+
+ <note><para>This doesn't cover network namespaces and is solely for
+ file system level isolation.</para></note>
+ '';
+ };
+
+ config = lib.mkIf config.chroot.enable {
+ serviceConfig = let
+ rootName = "${mkPathSafeName name}-chroot";
+ in {
+ RootDirectory = pkgs.runCommand rootName {} "mkdir \"$out\"";
+ TemporaryFileSystem = "/";
+ MountFlags = lib.mkDefault "private";
+ } // lib.optionalAttrs config.chroot.withBinSh {
+ BindReadOnlyPaths = [ "${pkgs.dash}/bin/dash:/bin/sh" ];
+ } // lib.optionalAttrs (config.chroot.confinement == "full-apivfs") {
+ MountAPIVFS = true;
+ PrivateDevices = true;
+ PrivateTmp = true;
+ PrivateUsers = true;
+ ProtectControlGroups = true;
+ ProtectKernelModules = true;
+ ProtectKernelTunables = true;
+ };
+ chroot.packages = let
+ startOnly = config.serviceConfig.RootDirectoryStartOnly or false;
+ execOpts = if startOnly then [ "ExecStart" ] else [
+ "ExecReload" "ExecStart" "ExecStartPost" "ExecStartPre" "ExecStop"
+ "ExecStopPost"
+ ];
+ execPkgs = lib.concatMap (opt: let
+ isSet = config.serviceConfig ? ${opt};
+ in lib.optional isSet config.serviceConfig.${opt}) execOpts;
+ in execPkgs ++ lib.optional config.chroot.withBinSh pkgs.dash;
+ };
+ }));
+ };
+
+ config.assertions = lib.concatLists (lib.mapAttrsToList (name: cfg: let
+ whatOpt = optName: "The 'serviceConfig' option '${optName}' for"
+ + " service '${name}' is enabled in conjunction with"
+ + " 'chroot.enable'";
+ in lib.optionals cfg.chroot.enable [
+ { assertion = !cfg.serviceConfig.RootDirectoryStartOnly or false;
+ message = "${whatOpt "RootDirectoryStartOnly"}, but right now systemd"
+ + " doesn't support restricting bind-mounts to 'ExecStart'."
+ + " 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.";
+ }
+ ]) config.systemd.services);
+
+ config.systemd.packages = lib.concatLists (lib.mapAttrsToList (name: cfg: let
+ rootPaths = let
+ contents = lib.concatStringsSep "\n" cfg.chroot.packages;
+ in pkgs.writeText "${mkPathSafeName name}-string-contexts.txt" contents;
+
+ chrootPaths = pkgs.runCommand "${mkPathSafeName name}-chroot-paths" {
+ closureInfo = pkgs.closureInfo { inherit rootPaths; };
+ serviceName = "${name}.service";
+ excludedPath = rootPaths;
+ } ''
+ mkdir -p "$out/lib/systemd/system"
+ serviceFile="$out/lib/systemd/system/$serviceName"
+
+ echo '[Service]' > "$serviceFile"
+
+ while read storePath; do
+ if [ -L "$storePath" ]; then
+ # Currently, systemd can't cope with symlinks in Bind(ReadOnly)Paths,
+ # so let's just bind-mount the target to that location.
+ echo "BindReadOnlyPaths=$(readlink -e "$storePath"):$storePath"
+ elif [ "$storePath" != "$excludedPath" ]; then
+ echo "BindReadOnlyPaths=$storePath"
+ fi
+ done < "$closureInfo/store-paths" >> "$serviceFile"
+ '';
+ in lib.optional cfg.chroot.enable chrootPaths) config.systemd.services);
+}
diff --git a/nixos/modules/system/boot/systemd-lib.nix b/nixos/modules/system/boot/systemd-lib.nix
index 68a40377ee13..28ad4f121bbe 100644
--- a/nixos/modules/system/boot/systemd-lib.nix
+++ b/nixos/modules/system/boot/systemd-lib.nix
@@ -9,12 +9,11 @@ in rec {
shellEscape = s: (replaceChars [ "\\" ] [ "\\\\" ] s);
+ mkPathSafeName = lib.replaceChars ["@" ":" "\\" "[" "]"] ["-" "-" "-" "" ""];
+
makeUnit = name: unit:
- let
- pathSafeName = lib.replaceChars ["@" ":" "\\" "[" "]"] ["-" "-" "-" "" ""] name;
- in
if unit.enable then
- pkgs.runCommand "unit-${pathSafeName}"
+ pkgs.runCommand "unit-${mkPathSafeName name}"
{ preferLocalBuild = true;
allowSubstitutes = false;
inherit (unit) text;
@@ -24,7 +23,7 @@ in rec {
echo -n "$text" > $out/${shellEscape name}
''
else
- pkgs.runCommand "unit-${pathSafeName}-disabled"
+ pkgs.runCommand "unit-${mkPathSafeName name}-disabled"
{ preferLocalBuild = true;
allowSubstitutes = false;
}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 2ddb54bcc3d7..fe67e2453505 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -216,6 +216,7 @@ in
switchTest = handleTest ./switch-test.nix {};
syncthing-relay = handleTest ./syncthing-relay.nix {};
systemd = handleTest ./systemd.nix {};
+ systemd-chroot = handleTest ./systemd-chroot.nix {};
taskserver = handleTest ./taskserver.nix {};
telegraf = handleTest ./telegraf.nix {};
tomcat = handleTest ./tomcat.nix {};
diff --git a/nixos/tests/systemd-chroot.nix b/nixos/tests/systemd-chroot.nix
new file mode 100644
index 000000000000..523e1ad9f4df
--- /dev/null
+++ b/nixos/tests/systemd-chroot.nix
@@ -0,0 +1,129 @@
+import ./make-test.nix {
+ name = "systemd-chroot";
+
+ machine = { pkgs, lib, ... }: let
+ testServer = pkgs.writeScript "testserver.sh" ''
+ #!${pkgs.stdenv.shell}
+ export PATH=${lib.escapeShellArg "${pkgs.coreutils}/bin"}
+ ${lib.escapeShellArg pkgs.stdenv.shell} 2>&1
+ echo "exit-status:$?"
+ '';
+
+ testClient = pkgs.writeScriptBin "chroot-exec" ''
+ #!${pkgs.stdenv.shell} -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: { description, config ? {}, testScript }: {
+ systemd.sockets."test${toString num}" = {
+ description = "Socket for Test Service ${toString num}";
+ wantedBy = [ "sockets.target" ];
+ socketConfig.ListenStream = "/run/test${toString num}.sock";
+ socketConfig.Accept = true;
+ };
+
+ systemd.services."test${toString num}@" = {
+ description = "Chrooted Test Service ${toString num}";
+ chroot = (config.chroot or {}) // { enable = true; };
+ serviceConfig = (config.serviceConfig or {}) // {
+ ExecStart = testServer;
+ StandardInput = "socket";
+ };
+ } // removeAttrs config [ "chroot" "serviceConfig" ];
+
+ __testSteps = lib.mkOrder num ''
+ subtest '${lib.escape ["\\" "'"] description}', sub {
+ $machine->succeed('echo ${toString num} > /teststep');
+ ${testScript}
+ };
+ '';
+ };
+
+ in {
+ imports = lib.imap1 mkTestStep [
+ { description = "chroot-only confinement";
+ config.chroot.confinement = "chroot-only";
+ testScript = ''
+ $machine->succeed(
+ 'test "$(chroot-exec ls -1 / | paste -sd,)" = bin,nix',
+ 'test "$(chroot-exec id -u)" = 0',
+ 'chroot-exec chown 65534 /bin',
+ );
+ '';
+ }
+ { description = "full confinement with APIVFS";
+ testScript = ''
+ $machine->fail(
+ 'chroot-exec ls -l /etc',
+ 'chroot-exec ls -l /run',
+ 'chroot-exec chown 65534 /bin',
+ );
+ $machine->succeed(
+ 'test "$(chroot-exec id -u)" = 0',
+ 'chroot-exec chown 0 /bin',
+ );
+ '';
+ }
+ { description = "check existence of bind-mounted /etc";
+ config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
+ testScript = ''
+ $machine->succeed('test -n "$(chroot-exec cat /etc/passwd)"');
+ '';
+ }
+ { description = "check if User/Group really runs as non-root";
+ config.serviceConfig.User = "chroot-testuser";
+ config.serviceConfig.Group = "chroot-testgroup";
+ testScript = ''
+ $machine->succeed('chroot-exec ls -l /dev');
+ $machine->succeed('test "$(chroot-exec id -u)" != 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 {
+ description = "check if symlinks are properly bind-mounted";
+ config.chroot.packages = lib.singleton symlink;
+ testScript = ''
+ $machine->fail('chroot-exec test -e /etc');
+ $machine->succeed('chroot-exec cat ${symlink} >&2');
+ $machine->succeed('test "$(chroot-exec cat ${symlink})" = "got me"');
+ '';
+ })
+ { description = "check if StateDirectory works";
+ config.serviceConfig.User = "chroot-testuser";
+ config.serviceConfig.Group = "chroot-testgroup";
+ config.serviceConfig.StateDirectory = "testme";
+ testScript = ''
+ $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');
+ '';
+ }
+ ];
+
+ 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.users.groups.chroot-testgroup = {};
+ config.users.users.chroot-testuser = {
+ description = "Chroot Test User";
+ group = "chroot-testgroup";
+ };
+ };
+
+ testScript = { nodes, ... }: ''
+ $machine->waitForUnit('multi-user.target');
+ ${nodes.machine.config.__testSteps}
+ '';
+}