nixos: add functions and documentation for escaping systemd Exec* directives
it's really easy to accidentally write the wrong systemd Exec* directive, ones that works most of the time but fails when users include systemd metacharacters in arguments that are interpolated into an Exec* directive. add a few functions analogous to escapeShellArg{,s} and some documentation on how and when to use them.
This commit is contained in:
parent
74f542c42e
commit
40a35299fa
6 changed files with 157 additions and 0 deletions
|
@ -90,6 +90,17 @@ modules: `systemd.services` (the set of all systemd services) and
|
||||||
`systemd.timers` (the list of commands to be executed periodically by
|
`systemd.timers` (the list of commands to be executed periodically by
|
||||||
`systemd`).
|
`systemd`).
|
||||||
|
|
||||||
|
Care must be taken when writing systemd services using `Exec*` directives. By
|
||||||
|
default systemd performs substitution on `%<char>` specifiers in these
|
||||||
|
directives, expands environment variables from `$FOO` and `${FOO}`, splits
|
||||||
|
arguments on whitespace, and splits commands on `;`. All of these must be escaped
|
||||||
|
to avoid unexpected substitution or splitting when interpolating into an `Exec*`
|
||||||
|
directive, e.g. when using an `extraArgs` option to pass additional arguments to
|
||||||
|
the service. The functions `utils.escapeSystemdExecArg` and
|
||||||
|
`utils.escapeSystemdExecArgs` are provided for this, see [Example: Escaping in
|
||||||
|
Exec directives](#exec-escaping-example) for an example. When using these
|
||||||
|
functions system environment substitution should *not* be disabled explicitly.
|
||||||
|
|
||||||
::: {#locate-example .example}
|
::: {#locate-example .example}
|
||||||
::: {.title}
|
::: {.title}
|
||||||
**Example: NixOS Module for the "locate" Service**
|
**Example: NixOS Module for the "locate" Service**
|
||||||
|
@ -153,6 +164,37 @@ in {
|
||||||
```
|
```
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
::: {#exec-escaping-example .example}
|
||||||
|
::: {.title}
|
||||||
|
**Example: Escaping in Exec directives**
|
||||||
|
:::
|
||||||
|
```nix
|
||||||
|
{ config, lib, pkgs, utils, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.echo;
|
||||||
|
echoAll = pkgs.writeScript "echo-all" ''
|
||||||
|
#! ${pkgs.runtimeShell}
|
||||||
|
for s in "$@"; do
|
||||||
|
printf '%s\n' "$s"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
args = [ "a%Nything" "lang=\${LANG}" ";" "/bin/sh -c date" ];
|
||||||
|
in {
|
||||||
|
systemd.services.echo =
|
||||||
|
{ description = "Echo to the journal";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
serviceConfig.ExecStart = ''
|
||||||
|
${echoAll} ${utils.escapeSystemdExecArgs args}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
```{=docbook}
|
```{=docbook}
|
||||||
<xi:include href="option-declarations.section.xml" />
|
<xi:include href="option-declarations.section.xml" />
|
||||||
<xi:include href="option-types.section.xml" />
|
<xi:include href="option-types.section.xml" />
|
||||||
|
|
|
@ -122,6 +122,25 @@
|
||||||
services) and <literal>systemd.timers</literal> (the list of
|
services) and <literal>systemd.timers</literal> (the list of
|
||||||
commands to be executed periodically by <literal>systemd</literal>).
|
commands to be executed periodically by <literal>systemd</literal>).
|
||||||
</para>
|
</para>
|
||||||
|
<para>
|
||||||
|
Care must be taken when writing systemd services using
|
||||||
|
<literal>Exec*</literal> directives. By default systemd performs
|
||||||
|
substitution on <literal>%<char></literal> specifiers in these
|
||||||
|
directives, expands environment variables from
|
||||||
|
<literal>$FOO</literal> and <literal>${FOO}</literal>, splits
|
||||||
|
arguments on whitespace, and splits commands on
|
||||||
|
<literal>;</literal>. All of these must be escaped to avoid
|
||||||
|
unexpected substitution or splitting when interpolating into an
|
||||||
|
<literal>Exec*</literal> directive, e.g. when using an
|
||||||
|
<literal>extraArgs</literal> option to pass additional arguments to
|
||||||
|
the service. The functions
|
||||||
|
<literal>utils.escapeSystemdExecArg</literal> and
|
||||||
|
<literal>utils.escapeSystemdExecArgs</literal> are provided for
|
||||||
|
this, see <link linkend="exec-escaping-example">Example: Escaping in
|
||||||
|
Exec directives</link> for an example. When using these functions
|
||||||
|
system environment substitution should <emphasis>not</emphasis> be
|
||||||
|
disabled explicitly.
|
||||||
|
</para>
|
||||||
<anchor xml:id="locate-example" />
|
<anchor xml:id="locate-example" />
|
||||||
<para>
|
<para>
|
||||||
<emphasis role="strong">Example: NixOS Module for the
|
<emphasis role="strong">Example: NixOS Module for the
|
||||||
|
@ -183,6 +202,36 @@ in {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
</programlisting>
|
||||||
|
<anchor xml:id="exec-escaping-example" />
|
||||||
|
<para>
|
||||||
|
<emphasis role="strong">Example: Escaping in Exec
|
||||||
|
directives</emphasis>
|
||||||
|
</para>
|
||||||
|
<programlisting language="bash">
|
||||||
|
{ config, lib, pkgs, utils, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.echo;
|
||||||
|
echoAll = pkgs.writeScript "echo-all" ''
|
||||||
|
#! ${pkgs.runtimeShell}
|
||||||
|
for s in "$@"; do
|
||||||
|
printf '%s\n' "$s"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
args = [ "a%Nything" "lang=\${LANG}" ";" "/bin/sh -c date" ];
|
||||||
|
in {
|
||||||
|
systemd.services.echo =
|
||||||
|
{ description = "Echo to the journal";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
serviceConfig.ExecStart = ''
|
||||||
|
${echoAll} ${utils.escapeSystemdExecArgs args}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
</programlisting>
|
</programlisting>
|
||||||
<xi:include href="option-declarations.section.xml" />
|
<xi:include href="option-declarations.section.xml" />
|
||||||
<xi:include href="option-types.section.xml" />
|
<xi:include href="option-types.section.xml" />
|
||||||
|
|
|
@ -45,6 +45,26 @@ rec {
|
||||||
replaceChars ["/" "-" " "] ["-" "\\x2d" "\\x20"]
|
replaceChars ["/" "-" " "] ["-" "\\x2d" "\\x20"]
|
||||||
(removePrefix "/" s);
|
(removePrefix "/" s);
|
||||||
|
|
||||||
|
# Quotes an argument for use in Exec* service lines.
|
||||||
|
# systemd accepts "-quoted strings with escape sequences, toJSON produces
|
||||||
|
# a subset of these.
|
||||||
|
# Additionally we escape % to disallow expansion of % specifiers. Any lone ;
|
||||||
|
# in the input will be turned it ";" and thus lose its special meaning.
|
||||||
|
# Every $ is escaped to $$, this makes it unnecessary to disable environment
|
||||||
|
# substitution for the directive.
|
||||||
|
escapeSystemdExecArg = arg:
|
||||||
|
let
|
||||||
|
s = if builtins.isPath arg then "${arg}"
|
||||||
|
else if builtins.isString arg then arg
|
||||||
|
else if builtins.isInt arg || builtins.isFloat arg then toString arg
|
||||||
|
else throw "escapeSystemdExecArg only allows strings, paths and numbers";
|
||||||
|
in
|
||||||
|
replaceChars [ "%" "$" ] [ "%%" "$$" ] (builtins.toJSON s);
|
||||||
|
|
||||||
|
# Quotes a list of arguments into a single string for use in a Exec*
|
||||||
|
# line.
|
||||||
|
escapeSystemdExecArgs = concatMapStringsSep " " escapeSystemdExecArg;
|
||||||
|
|
||||||
# Returns a system path for a given shell package
|
# Returns a system path for a given shell package
|
||||||
toShellPath = shell:
|
toShellPath = shell:
|
||||||
if types.shellPackage.check shell then
|
if types.shellPackage.check shell then
|
||||||
|
|
|
@ -459,6 +459,7 @@ in
|
||||||
systemd-boot = handleTest ./systemd-boot.nix {};
|
systemd-boot = handleTest ./systemd-boot.nix {};
|
||||||
systemd-confinement = handleTest ./systemd-confinement.nix {};
|
systemd-confinement = handleTest ./systemd-confinement.nix {};
|
||||||
systemd-cryptenroll = handleTest ./systemd-cryptenroll.nix {};
|
systemd-cryptenroll = handleTest ./systemd-cryptenroll.nix {};
|
||||||
|
systemd-escaping = handleTest ./systemd-escaping.nix {};
|
||||||
systemd-journal = handleTest ./systemd-journal.nix {};
|
systemd-journal = handleTest ./systemd-journal.nix {};
|
||||||
systemd-networkd = handleTest ./systemd-networkd.nix {};
|
systemd-networkd = handleTest ./systemd-networkd.nix {};
|
||||||
systemd-networkd-dhcpserver = handleTest ./systemd-networkd-dhcpserver.nix {};
|
systemd-networkd-dhcpserver = handleTest ./systemd-networkd-dhcpserver.nix {};
|
||||||
|
|
0
nixos/tests/empty-file
Normal file
0
nixos/tests/empty-file
Normal file
45
nixos/tests/systemd-escaping.nix
Normal file
45
nixos/tests/systemd-escaping.nix
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import ./make-test-python.nix ({ pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
echoAll = pkgs.writeScript "echo-all" ''
|
||||||
|
#! ${pkgs.runtimeShell}
|
||||||
|
for s in "$@"; do
|
||||||
|
printf '%s\n' "$s"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
# deliberately using a local empty file instead of pkgs.emptyFile to have
|
||||||
|
# a non-store path in the test
|
||||||
|
args = [ "a%Nything" "lang=\${LANG}" ";" "/bin/sh -c date" ./empty-file 4.2 23 ];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
name = "systemd-escaping";
|
||||||
|
|
||||||
|
machine = { pkgs, lib, utils, ... }: {
|
||||||
|
systemd.services.echo =
|
||||||
|
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ [] ])).success;
|
||||||
|
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ {} ])).success;
|
||||||
|
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ null ])).success;
|
||||||
|
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ false ])).success;
|
||||||
|
assert !(builtins.tryEval (utils.escapeSystemdExecArgs [ (_:_) ])).success;
|
||||||
|
{ description = "Echo to the journal";
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
serviceConfig.ExecStart = ''
|
||||||
|
${echoAll} ${utils.escapeSystemdExecArgs args}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
machine.succeed("systemctl start echo.service")
|
||||||
|
# skip the first 'Starting <service> ...' line
|
||||||
|
logs = machine.succeed("journalctl -u echo.service -o cat").splitlines()[1:]
|
||||||
|
assert "a%Nything" == logs[0]
|
||||||
|
assert "lang=''${LANG}" == logs[1]
|
||||||
|
assert ";" == logs[2]
|
||||||
|
assert "/bin/sh -c date" == logs[3]
|
||||||
|
assert "/nix/store/ij3gw72f4n5z4dz6nnzl1731p9kmjbwr-empty-file" == logs[4]
|
||||||
|
assert "4.2" in logs[5] # toString produces extra fractional digits!
|
||||||
|
assert "23" == logs[6]
|
||||||
|
'';
|
||||||
|
})
|
Loading…
Reference in a new issue