diff --git a/nixos/modules/system/service/systemd/service.nix b/nixos/modules/system/service/systemd/service.nix index 30c2335b81e3..5fdf70c53b08 100644 --- a/nixos/modules/system/service/systemd/service.nix +++ b/nixos/modules/system/service/systemd/service.nix @@ -58,6 +58,61 @@ in (lib.mkAliasOptionModule [ "systemd" "socket" ] [ "systemd" "sockets" "" ]) ]; options = { + systemd.lib = mkOption { + description = '' + Library functions for working with systemd services. + + Available functions: + + - `escapeSystemdExecArgs`: Escapes a list of arguments for use in ExecStart. + Prevents systemd's specifier (%) and variable ($) substitution by escaping + them to %% and $$ respectively. + + Example: `escapeSystemdExecArgs [ "/bin/echo" "Unit %n" ]` + produces `"/bin/echo" "Unit %%n"` + ''; + type = types.lazyAttrsOf types.raw; + readOnly = true; + }; + + systemd.mainExecStart = mkOption { + description = '' + Main command line for systemd's ExecStart with systemd's specifier and + environment variable substitution enabled. + + This option sets the primary ExecStart entry. Additional ExecStart entries + can be added via `systemd.service.serviceConfig.ExecStart` with `lib.mkBefore` + or `lib.mkAfter`. + + This option allows you to use systemd specifiers like `%n` (unit name), + `%i` (instance), `%t` (runtime directory), and environment variables using + `''${VAR}` syntax in your command line. + + By default, this is set to the escaped version of {option}`process.argv` + to prevent systemd substitution. Set this option explicitly to enable + systemd's substitution features. + + To extend {option}`process.argv` with systemd specifiers, you can append + to the escaped arguments: + + ```nix + systemd.mainExecStart = + config.systemd.lib.escapeSystemdExecArgs config.process.argv + " --systemd-unit %n"; + ``` + + This pattern allows you to pass the unit name (or other systemd specifiers) + as additional arguments while keeping the base command from {option}`process.argv` + properly escaped. + + See {manpage}`systemd.service(5)` (section "COMMAND LINES") for details on + variable substitution and {manpage}`systemd.unit(5)` (section "SPECIFIERS") + for available specifiers like `%n`, `%i`, `%t`. + ''; + type = types.str; + default = config.systemd.lib.escapeSystemdExecArgs config.process.argv; + defaultText = lib.literalExpression "config.systemd.lib.escapeSystemdExecArgs config.process.argv"; + }; + systemd.services = mkOption { description = '' This module configures systemd services, with the notable difference that their unit names will be prefixed with the abstract service name. @@ -106,6 +161,10 @@ in }; }; config = { + systemd.lib = { + inherit escapeSystemdExecArgs; + }; + # Note that this is the systemd.services option above, not the system one. systemd.services."" = { # TODO description; @@ -115,7 +174,7 @@ in Restart = lib.mkDefault "always"; RestartSec = lib.mkDefault "5"; ExecStart = [ - (escapeSystemdExecArgs config.process.argv) + config.systemd.mainExecStart ]; }; }; diff --git a/nixos/modules/system/service/systemd/test.nix b/nixos/modules/system/service/systemd/test.nix index 70d1b17d1a6d..17e38536b08d 100644 --- a/nixos/modules/system/service/systemd/test.nix +++ b/nixos/modules/system/service/systemd/test.nix @@ -52,6 +52,45 @@ let }; }; + # Test that systemd.mainExecStart overrides process.argv + # and allows systemd's specifier and variable substitution + system.services.argv-with-subst = { + process = { + argv = [ + hello' + "--greeting" + "This should be ignored" + ]; + }; + systemd.mainExecStart = ''/bin/sh -c "echo %n and ''${HOME}"''; + }; + + # Test that process.argv escapes % and $ by default + system.services.argv-escaped = { + process = { + argv = [ + "/bin/sh" + "-c" + "echo %n and \${HOME}" + ]; + }; + }; + + # Test extending process.argv with systemd specifiers + system.services.argv-extended = + { config, ... }: + { + process = { + argv = [ + hello' + "--greeting" + "Fun $1 fact, remainder is often expressed as m%n" + ]; + }; + systemd.mainExecStart = + config.systemd.lib.escapeSystemdExecArgs config.process.argv + " --systemd-unit %n"; + }; + # irrelevant stuff system.stateVersion = "25.05"; fileSystems."/".device = "/test/dummy"; @@ -83,6 +122,18 @@ runCommand "test-modular-service-systemd-units" grep 'ExecStart="${hello}/bin/hello" "--greeting" ".*database.*"' ${toplevel}/etc/systemd/system/bar-db.service >/dev/null grep -F 'RestartSec=42' ${toplevel}/etc/systemd/system/bar-db.service >/dev/null + # Test that systemd.mainExecStart overrides process.argv + # Note: %n and $HOME are NOT escaped, allowing systemd to substitute them + grep -F 'ExecStart=/bin/sh -c "echo %n and ''${HOME}"' ${toplevel}/etc/systemd/system/argv-with-subst.service >/dev/null + + # Test that process.argv escapes % as %% and $ as $$ + # This prevents systemd from performing specifier/variable substitution + grep -F 'ExecStart="/bin/sh" "-c" "echo %%n and $${HOME}"' ${toplevel}/etc/systemd/system/argv-escaped.service >/dev/null + + # Test extending process.argv with systemd specifiers + # The base command should be escaped ($1 -> $$1, m%n -> m%%n), but the appended --systemd-unit %n should not be + grep -F 'ExecStart="${hello}/bin/hello" "--greeting" "Fun $$1 fact, remainder is often expressed as m%%n" --systemd-unit %n' ${toplevel}/etc/systemd/system/argv-extended.service >/dev/null + [[ ! -e ${toplevel}/etc/systemd/system/foo.socket ]] [[ ! -e ${toplevel}/etc/systemd/system/bar.socket ]] [[ ! -e ${toplevel}/etc/systemd/system/bar-db.socket ]] diff --git a/pkgs/by-name/gh/ghostunnel/package.nix b/pkgs/by-name/gh/ghostunnel/package.nix index 6b90c1acb688..ae3525d3315a 100644 --- a/pkgs/by-name/gh/ghostunnel/package.nix +++ b/pkgs/by-name/gh/ghostunnel/package.nix @@ -5,8 +5,6 @@ lib, nixosTests, ghostunnel, - writeScript, - runtimeShell, }: buildGoModule rec { @@ -37,9 +35,7 @@ buildGoModule rec { passthru.services.default = { imports = [ - (lib.modules.importApply ./service.nix { - inherit writeScript runtimeShell; - }) + (lib.modules.importApply ./service.nix { }) ]; ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage }; diff --git a/pkgs/by-name/gh/ghostunnel/service.nix b/pkgs/by-name/gh/ghostunnel/service.nix index 4fb7db2c3150..52a2f4016e79 100644 --- a/pkgs/by-name/gh/ghostunnel/service.nix +++ b/pkgs/by-name/gh/ghostunnel/service.nix @@ -1,5 +1,5 @@ # Non-module dependencies (`importApply`) -{ writeScript, runtimeShell }: +{ }: # Service module { @@ -185,62 +185,56 @@ in # TODO assertions process = { - argv = - # Use a shell if credentials need to be pulled from the environment. - optional - (builtins.any (v: v != null) [ - cfg.keystore - cfg.cert - cfg.key - cfg.cacert - ]) - ( - writeScript "load-credentials" '' - #!${runtimeShell} - exec $@ ${ - concatStringsSep " " ( - optional (cfg.keystore != null) "--keystore=$CREDENTIALS_DIRECTORY/keystore" - ++ optional (cfg.cert != null) "--cert=$CREDENTIALS_DIRECTORY/cert" - ++ optional (cfg.key != null) "--key=$CREDENTIALS_DIRECTORY/key" - ++ optional (cfg.cacert != null) "--cacert=$CREDENTIALS_DIRECTORY/cacert" - ) - } - '' - ) - ++ [ - (getExe cfg.package) - "server" - "--listen" - cfg.listen - "--target" - cfg.target - ] - ++ optional cfg.allowAll "--allow-all" - ++ map (v: "--allow-cn=${v}") cfg.allowCN - ++ map (v: "--allow-ou=${v}") cfg.allowOU - ++ map (v: "--allow-dns=${v}") cfg.allowDNS - ++ map (v: "--allow-uri=${v}") cfg.allowURI - ++ optional cfg.disableAuthentication "--disable-authentication" - ++ optional cfg.unsafeTarget "--unsafe-target" - ++ cfg.extraArguments; + argv = [ + (getExe cfg.package) + "server" + "--listen" + cfg.listen + "--target" + cfg.target + ] + ++ optional cfg.allowAll "--allow-all" + ++ map (v: "--allow-cn=${v}") cfg.allowCN + ++ map (v: "--allow-ou=${v}") cfg.allowOU + ++ map (v: "--allow-dns=${v}") cfg.allowDNS + ++ map (v: "--allow-uri=${v}") cfg.allowURI + ++ optional cfg.disableAuthentication "--disable-authentication" + ++ optional cfg.unsafeTarget "--unsafe-target" + ++ cfg.extraArguments; }; } - // lib.optionalAttrs (options ? systemd) { - # refine the service - systemd.service = { - after = [ "network.target" ]; - wants = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Restart = "always"; - AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; - DynamicUser = true; - LoadCredential = - optional (cfg.keystore != null) "keystore:${cfg.keystore}" - ++ optional (cfg.cert != null) "cert:${cfg.cert}" - ++ optional (cfg.key != null) "key:${cfg.key}" - ++ optional (cfg.cacert != null) "cacert:${cfg.cacert}"; + # Refine the service for systemd + // lib.optionalAttrs (options ? systemd) ( + let + # Build credential flags with systemd variable substitution + credentialFlags = concatStringsSep " " ( + optional (cfg.keystore != null) "--keystore=\${CREDENTIALS_DIRECTORY}/keystore" + ++ optional (cfg.cert != null) "--cert=\${CREDENTIALS_DIRECTORY}/cert" + ++ optional (cfg.key != null) "--key=\${CREDENTIALS_DIRECTORY}/key" + ++ optional (cfg.cacert != null) "--cacert=\${CREDENTIALS_DIRECTORY}/cacert" + ); + in + { + # Use mainExecStart to add credential flags with systemd variable substitution + systemd.mainExecStart = + config.systemd.lib.escapeSystemdExecArgs config.process.argv + + lib.optionalString (credentialFlags != "") " ${credentialFlags}"; + + systemd.service = { + after = [ "network.target" ]; + wants = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Restart = "always"; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + DynamicUser = true; + LoadCredential = + optional (cfg.keystore != null) "keystore:${cfg.keystore}" + ++ optional (cfg.cert != null) "cert:${cfg.cert}" + ++ optional (cfg.key != null) "key:${cfg.key}" + ++ optional (cfg.cacert != null) "cacert:${cfg.cacert}"; + }; }; - }; - }; + } + ); }