Tomloader

Tomloader is an utility designed to facilitate the creation of multiple systemd unit files through grouping and reuse of systemd fields.

Table of Contents


1 Overview

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:

  1. first to all, you define one or more groups inside the (group) configuration file (see Group configuration for the syntax of the configuration file);
  2. then, for each unit you want to generate you must create a unit configuration file (see Unit configuration files for the syntax);
  3. finally, invoke the appropriate subcommand of 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:


1.1 Quick reference

This section provides a quick reference of subcommands provided by the tomloader executable. See The tomloader command and its subsections for a more detailed description.

tomloader

  • Get information about subcommand:
    tomloader help subcommand
    

    or

    tomloader subcommand --help
    
  • get installed version of tomloader:
    tomloader --version
    
  • change the configuration directory used by subcommand (see config-dir):
    tomloader -c config-dir subcommand
    

tomloader sd-v0.3

  • Generate a systemd unit from the unit configuration file unit-config-file and save the generated unit in target-dir:
    tomloader sd-v0.3 -t target-dir unit-config-dir
    
  • generate systemd units for every unit configuration file stored in source-dir:
    tomloader sd-v0.3 -t target-dir -d source-dir
    

tomloader inspect-v0.3

  • Show all saved groups:
    tomloader inspect-v0.3
    
  • show resolved dependencies and other settings of unit configuration file unit-conf:
    tomloader inspect-v0.3 -u unit-conf
    

tomloader sd-xdg-v0.3

  • Run XDG desktop applications app1.desktop, app2.desktop and app3.desktop in /usr/share/applications as systemd units:
    tomloader sd-xdg-v0.3 /usr/share/spplications/app1.desktop \
                    /usr/share/spplications/app2.desktop \
                    /usr/share/spplications/app3.desktop
    

    To customize the generated systemd unit, create or edit the XDG configuration file config_dir/xdg-apps.kdl. See XDG app configuration for syntax description.


1.2 Case study: sandboxing several units

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 command line;
  • a short description for your unit.

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 files for information about unit configuration files) inside the ~/units/ directory:


// ~/units/libreoffice_writer.service.kdl
pull {
    App "/usr/bin/libreoffice --writer" \
        "LibreOffice Writer -- sandboxed"
}

// ~/units/firefox.service.kdl
pull {
    App "/usr/lib/firefox/firefox" \
        "Firefox -- sandboxed"
}

Now if we run

    tomloader sd-v0.3 --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:

  • A single group called Sandbox will pull all the restrictions in each unit in which it is loaded;
  • additional groups, with the name beginning with 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=true

we do not want a sandboxed application to gain root privileges through SUID binaries;

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

other socket families are not required and could be used to exploit vulnerabilities;

SystemCallArchitectures=native

in 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 "/usr/bin/libreoffice --writer" \
        "LibreOffice Writer -- sandboxed"
    Sandbox
}

// ~/units/firefox.service.kdl
pull {
    App "/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 {
        section Service {
            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 "/usr/bin/libreoffice --writer" \
        "LibreOffice Writer -- sandboxed"
    Sandbox
}

// ~/units/firefox.service.kdl
pull {
    App "/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. However, Tomloader provides the special parameters ${DIR|HOME} and ${DIR|RUNTIME} which will either expand into ‘%h’, ‘%t’ or directly into the referred paths depending on the context:


def-group NoHome {
    sd {
        section Service {
            add TemporaryFileSystem "${DIR|HOME}" "${DIR|RUNTIME}"
        }
    }
}
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 {
        section Service {
            add BindPaths "-${DIR|HOME}/${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 {
        section Service {
            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 "-${DIR|DATA}/${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 {
        section Service {
            add BindPaths "-${DIR|RUNTIME}/wayland-0"
        }
    }
}

Then, we will modify our unit configuration file as follows:


// ~/units/libreoffice_writer.service.kdl
pull {
    App "/usr/bin/libreoffice --writer" \
        "LibreOffice Writer -- sandboxed"
    Sandbox
    AllowXDGDir "libreoffice"
    AllowWayland
    AllowDir "Documents/"
}

// ~/units/firefox.service.kdl
pull {
    App "/usr/lib/firefox/firefox" \
        "Firefox -- sandboxed"
    Sandbox
    AllowNet
    AllowWayland
    AllowDir "Downloads/"
    AllowDir ".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.

At this point, launching tomloader sd-v0.3 we will generate the two systemd units firefox.service and libreoffice_writer.service. Those services can then be started through systemctl --user start. However, you can only have a single instance of Firefox and Libreoffice Writer in this way since you cannot start two instances of the same systemd unit, unless the unit is a template and the two instances have a different argument.

A better approach is then to generate template units instead of normal units. To do that, you just need to rename firefox.service.kdl and libreoffice_writer.service.kdl into firefox@.service.kdl and libreoffice_writer@.service.kdl respectively. After genrating the respective template units with Tomloader, they can be started with the following command:

    systemctl --user start firefox@instance.service

where instance is a (random) string that uniquely identified the running instance of Firefox. This approach is more robust, however you should generate a random string for each instance. With version 0.3 Tomloader can generate and run templated systemd units from XDG Destop Entry Applications with the command

    tomloader sd-xdg-v0.3 xdg-desktop-entry-path

The behaviour of tomloader sd-xdg-v0.3 can be customized through the configuration file at ${XDG_CONFIG_HOME}/tomloader/xdg-apps.kdl. This configuration file has two root nodes: the apps node containing a list of configurations for specific XDG applications, and the default node that configures applications not listed in apps.


default {
    // default configuration
}
apps {
    xdg-app-id-1 {
        // configuration for xdg-app-id-1
    }
    xdg-app-id-2 {
        // configuration for xdg-app-id-2
    }
    // ...
}

Syntax for default node and children of apps matches unit configuration file’s one, with the addition of predefined positional parameters that can be used just like positional parameters in groups (see Parameters and Arguments). For example, ${0} expands to the command line available in the Exec= record of the desktop entry file, and ${1} expands to the content of Comment=.

On most Linux distributions, the Application ID of Libreoffice Writer is libreoffice-writer and that of Firefox is firefox. Therefore, our xdg-apps.kdl will look like


default {
    // default unit, restrict permissions as much as possible
    pull {
        Sandbox
        AllowWayland
        App "${0}" "${1}"
    }
}
apps {
    "libreoffice-writer" {
        pull {
            App "/usr/bin/libreoffice --writer" \
                "LibreOffice Writer -- sandboxed"
            Sandbox
            AllowXDGDir "libreoffice"
            AllowWayland
            AllowDir "Documents/"
        }
    }

    "firefox" {
        pull {
            App "/usr/lib/firefox/firefox" \
                "Firefox -- sandboxed"
            Sandbox
            AllowNet
            AllowWayland
            AllowDir "Downloads/"
            AllowDir ".mozilla/"
        }
    }
}

With this configuration, when we launch the following command

    tomloader sd-xdg-v0.3 /usr/share/applications/firefox.desktop

Tomloader will first infer the Application ID from the file name without .desktop extension (firefox), then it will found the firefox child of apps node and use it to generate a transient systemd unit named app-tomloader-firefox@.service. Finally, it generates a random string as instance and run app-tomloader-firefox@instance.service through systemd. See XDG app configuration for a deep description of the syntax.

This example ends here, you can find all the files created here in the directory examples/sandbox1 of this repo. 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). Moreover, example/sandbox_full contains more groups which are sufficient to build a sufficiently secure sandbox for your program. In order to use them, just be sure that your system supports unprivileged user namespaces.


2 The 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:

-c config_dir
--conf-dir config_dir

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.

-h
--help

Print a list of currently defined subcommands. Equivalent to invoking tomloader help.

-V
--version

Print the current version of tomloader.


2.1 The help subcommand

The help subcommand just prints a list of subcommands currently implemented.


2.2 The sd-v0.3 subcommand

The sd-v0.3 subcommand generates one or more systemd unit files from their respective unit configuration files. This is the general syntax of the sd-v0.3 subcommand:

tomloader sd-v0.3 [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.3:

-h
--help

Print a short help on standard output, then exit.

-V
--version

Print the current version of tomloader sd-v0.3.

-t outdir
--target-directory outdir

Save all generated systemd unit files in outdir. This option is mandatory.

-d srcdir
--directory srcdir

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.


2.3 The inspect-v0.3 subcommand

The inspect-v0.3 just prints on stdout all the load and remove dependencies for selected unit configuration files or all the groups defined in config_dir/groups.kdl.

tomloader inspect-v0.3 [options]

The following options are understood by tomloader inspect-v0.3:

-h
--help

Print a short help on standard output, then exit.

-V
--version

Print the current version of tomloader inspect-v0.3.

-g group
--group group

Prints on stdout dependencies and systemd fields in the specified group. This option can be specified multiple times.

-u conf_unit_path
--unit conf_unit_path

Prints on stdout dependencies and systemd fields in the unit configuration file with path conf_unit_path. This option can be specified multiple times.

If neither --unit nor --group are provided, then tomloader inspect-v0.3 prints all groups defined in conf-dir/groups.kdl.


2.4 The *-v0.1 and *-v0.2 subcommands

All the subcommands ending with ‘-v0.1’ or ‘-v0.2’ refer to old version of the command line interface or to old syntax for configuration files. They are deprecated, but you can still get information through the --help option.


2.5 The sd-xdg-v0.3 subcommand

The sd-xdg-v0.3 subcommand executes one or more XDG Desktop applications as a systemd user unit. Its configuration is stored at config_dir/xdg-apps.kdl. See XDG app configuration for the suntax of the xdg-apps.kdl file.

tomloader sd-xdg-v0.3 [options] xdg-app-paths...

The following options are understood by tomloader sd-xdg-v0.3:

-h
--help

Print a short help on standard output, then exit.

-V
--version

Print the current version of tomloader sd-xdg-v0.3.


3 Group configuration

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 section nodes representing systemd sections (e.g. Unit, Service, Slice). Within each section, the following operations are supported as child node:

set

assigns one or more values to a field;

reset

clears the field;

add

adds one or more values to the field.

The first argument of section, set, reset, add is the section/field name represented by such node. As remarked in the previous list, nodes set or add must have one or more additional KDL arguments represented as:

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:

Moreover, the following fields are treated differently:

DeviceAllow

instead of merge all the elements into a single one, a DeviceAllow= assigment is generated for each element, for example


    set DeviceAllow "/dev/ptmx rw" "char-drm r"

will generate (up to reordering)

    DeviceAllow=/dev/ptmx rw
    DeviceAllow=char-drm r

and not DeviceAllow=/dev/ptmx rw char-drm r which would be wrong in systemd.

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).


3.1 Declare dependencies

In the Overwiew, we explained load and remove dependencies for a group. More precisely, a load dependency group is a group that is loaded whenever the dependant is loaded, whereas a remove dependency is removed if the dependant is loaded.

May happen that the same group is declared both as a load and a remove dependency (usually by different groups). In that case, the remove dependency tooks precedence and the group is not loaded. Therefore, declaring a group as a load dependency is not a strong gaurantee that the group will at the end contribute to the generation of the systemd unit. On the other hand, declaring a group as a remove dependency definitively prevents it from contributing.

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.

We have explained that even if a group is declared as a load dependency (for example, by putting it inside the pull block) may still happen that it is not loaded in the final systemd unit. However, Tomloader gives you some control on what happens when a loaded dependency is blacklisted.

Dependencies declared inside the pull node may be declared needed or mandatory. A needed dependency is fundamental and the group cannot behave correctly without it. Marking a load dependency as needed does not prevent that dependency from being removed, because that would violate the load-remove relationship stated before. Instead, if a load needed dependency is blacklisted then Tomloader would either issue an error and block the unit generation, or just dropping the dependant group. Both these approaches do not violate the load-remove assumption and at the same time do not leave loaded any group with missing needed load dependencies.

Needed dependencies inside pull are declared through the needed child node:


def-node Group {
    pull {
        (group) NeededDep {
            needed #true
        }
    }
}

The needed node accepts either a boolean (a KDL boolean #true, #false or the special strings "true", "false" since double quotes " are mandatory for "true", "false" as specified by KDL specifications) or the value discard. If not specified, needed is set to false.

Setting needed to true will automatically block the generation of a systemd unit if the needed dependency has been blacklisted somewhere else during the generation. This approach is useful mostly when the unit is manually generated and the needed dependency must be present in the final systemd unit. In this way, tomloader will notice you if by mistake you have removed that dependency (usually by loading another group that removed it).

The special value discard for needed will instead unload the entire group if the dependency is unloaded. In the following example


def-group GroupA {}
def-group GroupB {
    pull {
        GroupA {
            needed discard
        }
    }
}

trying to load GroupB and at the same time blacklisting GroupA will still generate a unit but will automatically remove GroupB too. Several discard options could unload several groups at once, for example in


def-group GroupA {}
def-group GroupB {
    pull {
        GroupA {
            needed discard
        }
    }
}
def-group GroupC {
    pull {
        GroupC {
            needed discard
        }
    }
}

blacklisting GroupA will unload both GroupB and GroupC.

A group may appear in both pull and replace. In this case, the group itself is excluded while its dependencies remain included. Indeed, dependencies are transitive by default:

  1. dependencies declared inside pull propagate both their load and remove dependencies;
  2. dependencies declared inside replace propagate only their remove dependencies.

Transitive behaviour can be controlled through the child node inherit. For example, inherit #false will prevent all transitive dependencies from being loaded:


def-group Group7 {
    pull {
        // Group4 is loaded as a pull dependency
        // Group3 is loaded as a replace
        // dependency
        Group6
    }
}
def-group Group8 {
    pull {
        // only Group6 is loaded as load
        // dependency, no further groups
        // will be loaded here.
        Group6 {
            inherit #false
        }
    }
}

Warning: using inherit #false for a group dependency with needed dependencies will prevent those transitive dependencies to be loaded, which will usually result in an error or in cascading gropus unloading.

In general, inherit accepts any boolean as explained before, with inherit #true reverting to the default behaviour. For remove dependencies, inherit also accepts the string pulls as value. With this property, all the transitive load dependencies are instead loaded as remove 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:

  • transitive load dependencies are transitively loaded as load dependencies, unless inherit #false or inherit deep are specified;
  • needed dependencies of each group listed inside merge will be listed as needed by the whole group;
  • all the fields specifications are loaded in the current group as their own specifications that can still be modified in its own 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.
    }
    

Warning: transitive needed dependencies of a group listed in merge will still be imported even if inherit #false has been specified. Therefore, needed dependencies should be loaded elsewhere in order to prevent a generation failure or unloading the entire group.

The inherit child 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:

  • all load transitive dependencies are imported as remove dependencies instead;
  • transitive needed dependencies won’t be transitively imported as needed;
  • all the fields specified in these load transitive dependencies are loaded in the current group as its own specifications.

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.


3.2 Parameters and Arguments

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 to dependency groups as positional arguments:


def-group GroupB {
    pull {
        GroupA "pidname" "exec-sh"
    }
}
def-group GroupC 1 {
    pull {
        GroupA "${0}-A" "${0}-B"
    }
}

You can additionally use special non-positional parameters that will expand to some well-known paths or other useful strings. Up to now, the following keys are recognized:

${DIR|HOME}

home directory of current user;

${DIR|DATA}

XDG user data directory, equivalent to systemd specifier ‘%D’;

${DIR|CONFIG}

XDG user configuration directory, equivalent to systemd specifier ‘%E’;

${DIR|RUNTIME}

XDG user runtime directory, equivalent to systemd specifier ‘%t’;

${DIR|CACHE}

XDG user cache directory, equivalent to systemd specifier ‘%C’;

${SD|INSTANCE}

instance of the current systemd unit, equivalent to systemd specifier ‘%i’.


3.3 Environment variables

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:

  • you cannot overwrite or append new values to a single environment variable without clearing the entire Environment field;
  • if the content of an environment variable contains spaces, then you should remember to surround the entire assigment between two ‘\"’. In the previous example if you forgot to add the ‘\"’ and you instead write
    
    
    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 files).

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"

4 Unit configuration files

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 Declare 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.


4.1 Conflicts

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:

  • a group set a field and another group perform any modification to the same field;
  • a group 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.

sectionsetresetadd
sectionV, childs are merged
setCC
resetCCV
addCCCV, 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
    }
}

5 XDG app configuration

Tomloader is able to run XDG Destop entries as systemd user units. Without further configuration, an XDG destop entry named xdg_app will be executed as a systemd service unit named app-tomloader-xdg_app@random.service (with random a random ASCII string) with the following general structure:

[Unit]
Description=comment
PartOf=graphical-session.target
After=graphical-session.target
Requisite=graphical-session.target

[Service]
Type=exec
ExecStart=cmdline
Slice=app.slice

with comment and cmdline respectively the content of the fields Comment= and Exec= (accordingly sanitized) in the XDG Desktop entry.

If you want to customize the generated systemd unit, or you want to generate a different unit for an application, then you can use the XDG app configuration file config_dir/xdg-apps.kdl (see config-dir) to control the behaviour of tomloader sd-xdg-v0.3 (see The sd-xdg-v0.3 subcommand). This file is KDL formatted and contained the following top-level nodes, which can appear at most once in the file:

default

default model to use for apps not listed inside apps, it follows the same syntax of Unit configuration files but additionally you can use the following positional parameters:

${0}

(sanitized) command line derived from Exec= record;

${1}

comment record from Comment=;

${2}

XDG Application ID derived from the destop entry filename.

apps

list of apps for which a different user unit should be generated instead of the one provided by default, each child node mush have the target XDG Desktop ID as name and the content must follow the same rules of default.


default {
    pull {
        GroupA
    }
    sd {
        section Service {
            set Type exec
            set ExecStart "${0}"
            set NoNewPrivileges yes
        }
        section Unit {
            set Description "${1}"
        }
    }
}

apps {
    "org.mantainer.GUIShell" {
        sd {
            section Service {
                set Type exec
                set ExecStart "${0}"
                set NoNewPrivileges no
            }
            section Unit {
                set Description "${1}"
            }
        }
    }
}

Tip: All groups defined in config_dir/groups.kdl are still available. If you need to share several fields between XDG app configurations then you could put all of them inside a group and then use it just for desired applications.


6 In-depth analysis of basic concepts

This chapter is totally devoted to a comprehensive coverage of the basic concepts of Tomloader. Normally you do not needed to read this chapter for daily usage of Tomoader, unless if you wish to contribute to Tomloader or just understand how it works under the hood.


6.1 Groups and unit configurations

In Tomloader basic entities can be either groups and unit configurations. We have already described what is a group in previous chapters. On the other hand, an unit configuration is an entity that holds the description of a single (eventually templated) systemd unit. Like the name suggests, unit configurations are declared inside unit configuration files (see Unit configuration files), inside default nodes or as children of apps in conf-dir/xdg-apps.kdl.

Both groups and unit configurations can contain systemd fields which can be combined in order to generate the final systemd unit. To generate a systemd unit you need to provide:

  • a single unit configuration;
  • zero or more groups.

You cannot generate a systemd unit from a collection of groups without providing an unit configuration, and at the same time you cannot combine two unit configurations into a single unit. If you need to share fields between different unit configurations just use a group.

Tomloader follows the following passages to generate an unit from an unit configuration and a collection of groups.

  1. All the fields stored inside the groups are collected and merged into a single list of fields. If two or more groups try to modify the same field, then both changes are applied to that field in such a way that the final value does not depend by the order of those changes. If there is no way to produce a result regardless of the order, that field is marked as "undefined". See Conflicts for a precise description on how fields are merged.
  2. After merging the groups, field modifications defined in the unit configuration are applied to that merged field list. Now changes performed by the unit configuration can overwrite fields previously defined inside groups, in particular you can set a value or reset undefined fields.
  3. If there are still undefined fields then an error is issued and the unit is not generated. If not, then those fields are used to generate the final systemd unit.

6.2 Dependencies

At the beginning of the present manual, we have introduced load and remove dependencies. Both can be defined for groups and unit configurations, however only groups can be specified as dependencies, not unit configurations. Load dependencies for an unit configuration are the groups that will be effectively used to generate the systemd unit as explained in previous section, after removing all groups specified as remove dependencies for the same unit configuration.

In practice, dependencies are not transitive, which means that load/remove dependencies of a group are not automatically imported in the unit configuration that specifies that group as dependency. Instead, the specific node used to specify the dependency (pull, replace, merge) together with the inherit property decides which transitive dependencies to import in order to simulate transitiveness.

To show how dependencies are imported, we call host the group/unit configuration that uses one of the previous nodes to declare its dependencies, and target a group that is specified inside one of the previous nodes. Then:

  • setting inherit=#false blocks any dependency of target from being imported into the host, regardless to the used node;
  • if target is listed in pull then it is imported as a load dependency of the host, and all its load/remove dependencies are in turn imported as load/remove dependencies respectively;
  • if target is listed in replace then it is imported as a remove dependency of the host and all its remove dependencies are imported as remove dependencies. Moreover, if inherit is set to pulls then its load dependencies are imported as remove dependencies of the host;
  • if target is listed in merge then it is imported as a remove dependency but all its load/remove dependencies are imported as load/remove dependencies of the host respectively. Moreover, if inherit is set to deep then load dependencies of target are instead imported as remove dependencies for the host.

Since there are no transitive dependencies, the needed property is never imported through pull and replace, and inherited only in certain cases by merge.


Index