Tomloader is an utility designed to facilitate the creation of multiple systemd unit files through grouping and reuse of systemd fields.
The primary purpose of Tomloader is to simplify the managment of multiple systemd unit files that share configuration fields.
Systemd already provides native support for configuration reuse through .conf files inside drop-in directories (.d/) which can be hierarchically organized (for example, unit A-B-C.unit inherits all drop-in configurations listed in A-B-.unit.d/ and A-.unit.d/). While useful, it present some limitations:
Although symlinks may be used the first issue, they do no address the lack of parametrization. Additionally, ordering constraints remain a source of potential misconfiguration even with naming conventions like numeric prefixes.
Tomloader addresses these limitations with the introduction of groups. A group is a collection of systemd configuration fields which may declare dependencies on other groups. Despite .conf files in drop-in directories:
The typical workflow for generating systemd units with Tomloader is the following:
tomloader in order to generate your units (see The tomloader command for a list of subcommands).
Groups are loaded during the generation of a systemd unit. When a group is loaded, all its systemd configuration fields are imported into the unit. A loaded group can still be unloaded and all its fields reverted to their original stated. A group can be unloaded because another group providing similar functionalities is loaded and imported in the generated unit, or just because it is loaded as a dependency of another group but it is not needed.
It is not important the order the groups are loaded in the generated systemd unit: the resulting systemd unit will be the same as long as the same groups (with the same parameters) are loaded into.
There are two kinds of dependencies for a group:
Let us assume we have several applications (maybe described inside .desktop files) which we want to start as systemd user units. We already know that they are not malware, but at the same time we do not fully trust their developers, maybe because in the past we have found several critical vulnerabilities in released code, or maybe they collect as much data as possible from your home directory (especially if the application is closed-source). Therefore, you want to set up a simple sandbox for those programs.
Assume also that you do not want to rely on a battle-tested sandbox engine like Firejail, Bubblejail, or a full container like LXC or systemd-nspawn: you want to do everything by yourself through systemd units. Indeed, systemd already provides you a lot of sandboxing options to limit access to your home directory. We also assume that you have user namespaces enabled for unprivilegied users in order to allow systemd user units to use namespaces.
First to all, systemd has several service units types. As suggested in systemd.service(5), we will use the type exec for most part of our units. Therefore, we edit the configuration file located at ${XDG_CONFIG_HOME}/tomloader/groups.kdl with the definition of a group named App with two mandatory ordered parameters:
The configuration file will be
// ${XDG_CONFIG_HOME}/tomloader/groups.kdl def-group App 2 { sd { (section) Unit { (set) Description "${1}" } (section) Service { (set) Type "exec" (set) ExecStart "${0}" } } }
See Group configuration for information about groups and the syntax of the configuration file. For simplicity, we will sandbox just two applications as systemd user units: LibreOffice Writer and Firefox. For each application/systemd unit, we will create an unit configuration file (see Unit configuration for information about unit configuration files) inside the ~/units/ directory:
// ~/units/libreoffice_writer.service.kdl pull { App { args "/usr/bin/libreoffice --writer" \ "LibreOffice Writer -- sandboxed" } }
// ~/units/firefox.service.kdl pull { App { args "/usr/lib/firefox/firefox" \ "Firefox -- sandboxed" } }
Notice that all the arguments passed to a group must be specified inside a child args node instead of passed directly as argument to the group node, because group nodes may support in future more options that would clash with user arguments.
Now if we run
tomloader sd-v0.2 --directory ~/units/ --target-directory \
${XDG_CONFIG_HOME}/systemd/user/
then the following systemd user units will be generated in your home:
// ${XDG_CONFIG_HOME}/systemd/user/libreoffice_writer.service
[Unit]
Description=LibreOffice Writer -- sandboxed
[Service]
Type=exec
ExecStart=/usr/bin/libreoffice --writer
// ${XDG_CONFIG_HOME}/systemd/user/firefox.service
[Unit]
Description=Firefox -- sandboxed
[Service]
Type=exec
ExecStart=/usr/lib/firefox/firefox
For the sandbow, we will implement a whitelist approach:
Sandbox will pull all the restrictions in each unit in which it is loaded;
Allow, may be loaded in order to relax the very strong limitations brought by Sandbox.
In this way, we will have a greater control on the application we will execute with a lesser risk of information leaks.
The following restrictions listed in Sandbox will always be enabled:
NoNewPrivileges=truewe do not want a sandboxed application to gain root privileges through SUID binaries;
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6other socket families are not required and could be used to exploit vulnerabilities;
SystemCallArchitectures=nativein this example we do not need to execute x86 binaries on a x86_64 machine.
Our configuration files become:
// ${XDG_CONFIG_HOME}/tomloader/groups.kdl def-group Sandbox { sd { (section) Service { (set) NoNewPrivileges #true (add) RestrictAddressFamilies \ "AF_UNIX" "AF_INET" "AF_INET6" (set) SystemCallArchitectures native } } } def-group App 2 { sd { (section) Unit { (set) Description "${1}" } (section) Service { (set) Type "exec" (set) ExecStart "${0}" } } }
// ~/units/libreoffice_writer.service.kdl pull { App { args "/usr/bin/libreoffice --writer" \ "LibreOffice Writer -- sandboxed" } Sandbox }
// ~/units/firefox.service.kdl pull { App { args "/usr/lib/firefox/firefox" \ "Firefox -- sandboxed" } Sandbox }
We used an (add) node instead of a (set) node for RestrictAddressFamilies in order to allow more socket families without necessarily generate conflicts (see Conflicts for a brief description of conflicts and how to resolve them).
At this point we can add personalized restrictions for our units. First to all, we want to limit network access for those applications that should be able to connect through Internet. The easiest way to implement it is through the PrivateNetwork systemd field which we will put in a group called NoNet:
def-group NoNet { sd { (set) PrivateNetwork #true } }
This group will be set as a load dependency of Sandbox in order to disallow network access by default:
def-group Sandbox { pull { NoNet } sd { (section) Service { (set) NoNewPrivileges #true (add) RestrictAddressFamilies \ "AF_UNIX" "AF_INET" "AF_INET6" (set) SystemCallArchitectures native } } }
In order to allow network access for Firefox, we create another group called AllowNet which only purpose is to remove NoNet when loaded:
def-group AllowNet { replace { NoNet } }
and modify our unit configuration files as follows:
// ~/units/libreoffice_writer.service.kdl pull { App { args "/usr/bin/libreoffice --writer" \ "LibreOffice Writer -- sandboxed" } Sandbox }
// ~/units/firefox.service.kdl pull { App { args "/usr/lib/firefox/firefox" \ "Firefox -- sandboxed" } Sandbox AllowNet }
Next, we want to disallow unrestricted access to our home directory. We can implement this in Systemd by mounting a temporary filesystem at our HOME and at our XDG_RUNTIME_DIR, which are represented in a systemd unit file with ‘%h’ and ‘%t’ respectively:
def-group NoHome { sd { (add) TemporaryFileSystem "%h" "%t" } } def-group Sandbox { pull { NoNet NoHome } sd { (section) Service { (set) NoNewPrivileges #true (add) RestrictAddressFamilies \ "AF_UNIX" "AF_INET" "AF_INET6" (set) SystemCallArchitectures native } } }
Instead of introducing a group which would allow unrestricted access to the home directory, we could instead provide access to just few authorized subdirectories. Since we have mounted a temporary filesystem on our home, we can use bind mounts to give access to underlying directories:
def-group AllowDir 1 { pull { NoHome } sd { (add) BindPaths "-%h/${0}" } }
but we can also use ConfigurationDirectory, RuntimeDirectory and CacheDirectory systemd fields to provide access to safe directories depending on XDG_* environment variables:
def-group AllowXDGDir 1 { pull { NoHome } sd { (set) ConfigurationDirectory "${0}" (set) RuntimeDirectory "${0}" (set) CacheDirectory "${0}" // Currently, systemd does not provide a similar // option for XDG_DATA_HOME, therefore // we should use BindPaths here (add) BindPaths "-%D/${0}" } }
Notice that, since both our applications are graphical, we need to enable access to the Wayland socket. For simplicity, we will assume here that its position is at ${XDG_RUNTIME_DIR}/wayland-0:
def-group AllowWayland { pull { NoHome } sd { (add) BindPaths "-%t/wayland-0" } }
Then, we will modify our unit configuration file as follows:
// ~/units/libreoffice_writer.service.kdl pull { App { args "/usr/bin/libreoffice --writer" \ "LibreOffice Writer -- sandboxed" } Sandbox AllowXDGDir { args "libreoffice" } AllowWayland AllowDir { args "Documents/" } }
// ~/units/firefox.service.kdl pull { App { args "/usr/lib/firefox/firefox" \ "Firefox -- sandboxed" } Sandbox AllowNet AllowWayland AllowDir { args "Downloads/" } AllowDir { args ".mozilla/" } }
It is not a problem to load AllowDir several times, even with different arguments, because several ‘(add)’ operations on the same field will always lead to a deterministic value that will not depend on the order of these operations. Therefore, even if we load AllowDir ".mozilla/" before AllowDir "Downloads/" the final value assigned to BindPaths will be the same. Tomloader achieves this by reordering values set by ‘(set)’ and ‘(add)’ operations in a deterministic way which at the moment is not stabilized and may change in future.
Therefore, you should use ‘(add)’ with several values only on systemd fields that doesn’t depend on the ordering. If ordering matters then the full preformatted string should be used through a single ‘(set)’ operation.
This example ends here. You can use it as a basis to implement a basic sandbox for your (sufficiently-trusted) applications. Additional sandboxing options for systemd can be found at systemd.service(5).
tomloader command ¶The tomloader executable generates systemd unit files from .kdl configuration files. A generic invocation of the tomloader utility has the following syntax:
tomloader subcommand [options]... other subcommands... [args]...
All the functionalities of tomloader are implemented through subcommands, and at least one of them must be specified. Backward-incompatible changes are introduced through new subcommands, existing subcommands remain backward-compatible, except where changes are required for security or deprecation reasons.
Without any subcommand, only the following options are understood by tomloader:
Path to the directory containing configuration files like groups.kdl. If not an absolute path (starts with ‘/’) then its location will be relative to XDG_CONFIG_HOME. Default to tomloader.
Print a list of currently defined subcommands. Equivalent to invoking tomloader help.
Print the current version of tomloader.
help subcommand ¶The help subcommand just prints a list of subcommands currently implemented.
sd-v0.2 subcommand ¶The sd-v0.2 subcommand generates one or more systemd unit files from their respective unit configuration files. This is the general syntax of the sd-v0.2 subcommand:
tomloader sd-v0.2 [options] <unit config files>...
where <unit config files> is a whitespace-separated list of files or paths pointing to unit configuration files.
The following options are understood by tomloader sd-v0.2:
Print a short help on standard output, then exit.
Print the current version of tomloader sd-v0.2.
Save all generated systemd unit files in outdir. This option is mandatory.
In addition to unit configuration files listed as <unit config files>, search unit configuration files stored in srcdir directory. This option can be specified multiple times. Only regular files are checked, it does not traverse subdirectories or follow symlinks.
inspect-v0.2 subcommand ¶The inspect-v0.2 just prints on stdout all the load and remove dependencies for selected unit configuration files or all the groups defined in ${XDG_CONFIG_HOME}/tomloader/groups.kdl.
The following options are understood by tomloader inspect-v0.2:
Print a short help on standard output, then exit.
Print the current version of tomloader inspect-v0.2.
Prints on stdout a list of load and remove dependencies of the unit configuration file with path CONF_UNIT_PATH. This option can be specified multiple times, and if you do not provide this option then the dependencies or all groups defined in groups.kdl.
sd-v0.1 subcommand ¶The sd-v0.1 subcommand, like sd-v0.2, generates one or more systemd unit files from their respective unit configuration files. The main difference between sd-v0.1 and sd-v0.2 is that sd-v0.1 only works with the v0.1 syntax for group and unit configuration file format, whereas sd-v0.2 only works with the v0.2 syntax.
See The sd-v0.2 subcommand, for documentation of the new command.
Groups are defined inside the (group) configuration file, which is located at config_dir/groups.kdl (see config-dir). This file is formatted as a KDL configuration file, you can find the official specification on KDL website. Each group is declared by using a def-group node:
def-group Group1 { sd { (section)Unit { (add) After "dbus.service" \ "pipewire.service" (add) Requisite "dbus.service" \ "pipewire.service" (reset) JoinsNamespaceOf (set) Description "Group1" } } } def-group Group2 { sd { (section) Unit { (set) Documentation "man:group(2)" } (section) Service { (set) Type "exec" } } }
Any valid KDL string can be used as group name, in particular you can embed whitespaces if you surround the whole name of the group between double quotes ‘"’.
Each group may contain zero or more sd nodes containing rules for Systemd fields. Each sd node contains nodes with type section (enclosed in parenthesis) representing systemd sections (e.g. Unit, Service, Slice). Within each section, the following operations are supported as node types:
set ¶assigns one or more values to a field;
reset ¶clears the field;
add ¶adds one or more values to the field.
The names of the nodes with type section, set, reset, add is the section/field name represented by such node. As remarked in the previous list, nodes with type set or add must have one or more KDL arguments represented as:
#true, #false);
Tomloader will automatically convert these objects as strings in the canonical way, in particular both the KDL boolean #true and the KDL quoted string "true" are seen by Tomloader as the string true.
Field values are internally represented as a list of strings. Just before generating the systemd unit file each list of strings is merged into a single:
ExecSearchPath;
RootImageOptions.
Note: The order of values specified in set and add is not preserved, therefore in the generated unit they may appear in a different order (which will still be deterministic). If the order of those elements must be preserved, then a single set operation with a single string containing the ordered values is sufficient (Tomloader never adds double quotes ‘"’ to string values unless they are already present inside the value).
Groups declare their load and remove dependencies as child nodes inside pull and replace nodes respectively. Each node representing a dependency must have the respective group name as node name and may optionally have group as node type. For example:
def-group Group3 {} def-group "Group 4" { pull { (group)Group3 } } def-group Group5 { pull { (group) Group3 "Group 4" } } def-group Group6 { pull { "Group 4" } replace { Group3 } }
A group listed as load dependency can still be prevented to be loaded if another group lists the same group as a remove dependency. On the contrary, groups listed as remove dependencies cannot be included by any mean because it is not possible to revert a remove dependency. The only way to load a group declared as remove dependency is to prevent the group that specifies it as a remove dependency to be loaded.
A group may appear in both pull and replace. In this case, the group itself is excluded while its dependencies remain included. Dependencies are transitive by default:
Transitive behaviour can be disabled with the inherit=#false property in group nodes:
def-group Group7 { pull { // Group4 is loaded as a pull dependency // Group3 is loaded as a replace // dependency Group6 } } def-group Group8 { pull { // No further groups are loaded as // dependency Group6 inherit=#false } }
You can also assign the string values "true", "false" to inherit property in place of boolean values #true, #false (double quotes " are mandatory for "true", "false" as specified by KDL specifications). For remove dependencies, the inherit also accepts the string pulls as value. With this property, all the transitive pull dependencies are loaded as replace dependencies.
def-group Group9 { replace { // Group3 and Group4 are loaded as // replace dependencies Group6 inherit=pulls } }
Groups can declare dependencies also inside a merge node the same way you declare them in pull or replace node. Groups specified inside a merge are loaded as remove dependencies but with the following differences from usual remove dependencies:
inherit=#false or inherit=deep are specified;
sd node, for example
def-group GroupA { sd { (section) Unit { (set) StopWhenUnneeded #true (set) RefuseManualStart #true } } } def-group GroupB { merge { GroupA } sd { (section) Unit { (reset) RefuseManualStart } } // Now GroupA is listed as a remove // dependency, GroupB sets the // StopWhenUnneeded field in [Unit] // to true when loaded but resets the // RefuseManualStart field. }
The inherit property of a dependency specified inside a merge node accepts the special value deep other than #true and #false. For each dependency inside merge with inherit=deep:
Loading multiple groups inside merge node may generate conflits when one or more fields are modified by different groups. Section Conflicts explains how to manage and resolve conflicts.
A group may define one or more parameters through an additional argument of the def-group node with the total number of parameters:
def-group GroupA 2 { sd { (section) Service { (set) PIDFile "/run/${0}-pid" (set) ExecStartPre "/usr/bin/pre-${1}" } } }
Parameters can be accessed in string values or dependency arguments through ${0}, ${1}, ..., ${N-1} specifiers, which are replaced with their respective values when the unit is instantiated. In the previous example, loading GroupA with arguments "P1" and P2 will set the value of PIDFile field to /run/P1-pid and ExecStartPre field to /usr/bin/pre-P2. You cannot apply these specifiers to field names.
Additionally, the special specifier ${$} expands into a literal $.
Arguments are provided through a child args node:
def-group GroupB { pull { GroupA { args "pidname" "exec-sh" } } } def-group GroupC 1 { pull { GroupA { args "${0}-A" "${0}-B" } } }
Systemd service units has the Environment field in the Service section to manage environment variables of the executable. This field contains a space-separated list of elements in the form key=value (or "key=value" if value contain spaces) where key is the environment variable name and value is its content.
def-group GroupA { sd { (section) Service { (add) Environment "KEY_A=A" "KEY_B=B" "\"KEY_C=C SPACES\"" } } }
This way to manage environment variables has several drawbacks:
Environment field;
def-group GroupA { sd { (section) Service { (add) Environment "KEY_A=A" "KEY_B=B" "KEY_C=C SPACES" } } }
then the generated systemd unit will contain the wrong assigment
[Service] Environment=KEY_A=A KEY_B=B KEY_C=C SPACES
For those reasong, Tomloader also provides the node envs in order to manage environment variables like any other systemd field. Like section nodes, the envs node contains child nodes with type set, reset, add whose name is exactly the correspective environment variable.
Caution: the reset operation just removes that variable from the list, it does neither set the content to an empty string nor unset it. If you want to unset a specific environment variable then in addition you should add it to the UnsetEnvironment systemd field.
def-group GroupA { envs { (set) KEY_A A (set) KEY_B B (set) KEY_C C SPACES } }
Just like other fields, child nodes in envs may accept several values that will be merged at the end as a whitespace-separated string. Just before the generation of the systemd unit, all envs blocks will be merged into a single (add) Environment with proper quotation in order to preserve correct values. Therefore, the last example will be equivalent to the first one (if no additional group is loaded).
You can still clear and/or fully rewrite the content of the Environment field in the unit configuration file (see Unit configuration).
Caution: Tomloader always reorder values provided to a field as separate strings, which is problematic for several environment variables like PATH. Therefore, we do not provide special handling of colon-separated environment variables like PATH and instead encourage you to provide the full value in a single set operation:
(set) PATH "/home/tomloader/bin:/home/tomloader/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
To generate a systemd unit named unitname.ext, a corresponding KDL-formatted unit configuration file named unitname.ext.kdl must be provided.
You can use the pull, replace (but not merge), envs and sd nodes inside each unit configuration file to describe your systemd unit, see Group configuration, Environment variables and Dependencies for a description of these nodes.
Example:
pull { (group) Group1 Group2 } sd { (section) Unit { (set) Description "Unit" } (section) Service { (set) ExecStart "/usr/bin/bash" } }
Just like the respective nodes inside group specification, you can use the pull node for listing groups to include, and the replace node for listing groups to exclude.
When two or more groups try to modify the same field in an incompatible way then a conflict is generated that will prevent the systemd unif file to be generated. Modifying a field in an incompatible way means any sequence of modifications such that the final result will depend on the order in which these operations are performed.
These are some common situations that issue a conflict:
set a field and another group perform any modification to the same field;
add a field and another group reset the same field.
Notice that add operations on the same field do not generate conflicts because ordering is not preserved by add operations, in this way Tomloader can rearrange the values in order to make the final result independent on the order of the add operations.
The following table shows all the possible outcomes when two nodes try to modify the came field. The ‘C’ outcome means a conflict, the ‘V’ outcome means no conflict will be generated, with an explaination on how they interact.
section | set | reset | add | |
|---|---|---|---|---|
section | V, childs are merged | |||
set | C | C | ||
reset | C | C | V | |
add | C | C | C | V, all the values are added |
To resolve a conflict, you should specify inside the sd node of the respective unit configuration file the final value for the conflicting fields. Only set and reset operations can be used to resolve a conflict.
// groups.kdl def-group GroupA { sd { (section) Service { (set) Type simple } } } def-group GroupB { sd { (section) Service { (set) Type exec } } } // unit.service.kdl pull { GroupA GroupB // A conflict is issued because both GroupA // and GroupB try to set the Type field. // // This conflict is solved in the next sd // node. } sd { (section) Service { (set) Type oneshot } }
| Jump to: | $
A C D E G I M P R S T U |
|---|
| Jump to: | $
A C D E G I M P R S T U |
|---|