Modular services (#372170)

This commit is contained in:
Robert Hensing
2025-07-24 16:46:34 +02:00
committed by GitHub
19 changed files with 1136 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ let
inherit (pkgs) buildPackages runCommand docbook_xsl_ns; inherit (pkgs) buildPackages runCommand docbook_xsl_ns;
inherit (pkgs.lib) inherit (pkgs.lib)
evalModules
hasPrefix hasPrefix
removePrefix removePrefix
flip flip
@@ -117,8 +118,41 @@ let
${testOptionsDoc.optionsJSON}/${common.outputPath}/options.json ${testOptionsDoc.optionsJSON}/${common.outputPath}/options.json
sed -e '/@PYTHON_MACHINE_METHODS@/ {' -e 'r ${testDriverMachineDocstrings}/machine-methods.md' -e 'd' -e '}' \ sed -e '/@PYTHON_MACHINE_METHODS@/ {' -e 'r ${testDriverMachineDocstrings}/machine-methods.md' -e 'd' -e '}' \
-i ./development/writing-nixos-tests.section.md -i ./development/writing-nixos-tests.section.md
substituteInPlace ./development/modular-services.md \
--replace-fail \
'@PORTABLE_SERVICE_OPTIONS@' \
${portableServiceOptions.optionsJSON}/${common.outputPath}/options.json
substituteInPlace ./development/modular-services.md \
--replace-fail \
'@SYSTEMD_SERVICE_OPTIONS@' \
${systemdServiceOptions.optionsJSON}/${common.outputPath}/options.json
''; '';
portableServiceOptions = buildPackages.nixosOptionsDoc {
inherit (evalModules { modules = [ ../../modules/system/service/portable/service.nix ]; }) options;
inherit revision warningsAreErrors;
transformOptions =
opt:
opt
// {
# Clean up declaration sites to not refer to the NixOS source tree.
declarations = map stripAnyPrefixes opt.declarations;
};
};
systemdServiceOptions = buildPackages.nixosOptionsDoc {
inherit (evalModules { modules = [ ../../modules/system/service/systemd/service.nix ]; }) options;
# TODO: filter out options that are not systemd-specific, maybe also change option prefix to just `service-opt-`?
inherit revision warningsAreErrors;
transformOptions =
opt:
opt
// {
# Clean up declaration sites to not refer to the NixOS source tree.
declarations = map stripAnyPrefixes opt.declarations;
};
};
in in
rec { rec {
inherit (optionsDoc) optionsJSON optionsNix optionsDocBook; inherit (optionsDoc) optionsJSON optionsNix optionsDocBook;

View File

@@ -12,4 +12,5 @@ writing-documentation.chapter.md
nixos-tests.chapter.md nixos-tests.chapter.md
developing-the-test-driver.chapter.md developing-the-test-driver.chapter.md
testing-installer.chapter.md testing-installer.chapter.md
modular-services.md
``` ```

View File

@@ -0,0 +1,98 @@
# Modular Services {#modular-services}
Status: in development. This functionality is new in NixOS 25.11, and significant changes should be expected. We'd love to hear your feedback in <https://github.com/NixOS/nixpkgs/pull/372170>
Traditionally, NixOS services were defined using sets of options *in* modules, not *as* modules. This made them non-modular, resulting in problems with composability, reuse, and portability.
A configuration management framework is an application of `evalModules` with the `class` and `specialArgs` input attribute set to particular values.
NixOS is such a configuration management framework, and so are [Home Manager](https://github.com/nix-community/home-manager) and [`nix-darwin`](https://github.com/lnl7/nix-darwin).
The service management component of a configuration management framework is the set of module options that connects Nix expressions with the underlying service (or process) manager.
For NixOS this is the module wrapping [`systemd`](https://systemd.io/), on `nix-darwin` this is the module wrapping [`launchd`](https://en.wikipedia.org/wiki/Launchd).
A *modular service* is a [module] that defines values for a core set of options declared in the service management component of a configuration management framework, including which program to run.
Since it's a module, it can be composed with other modules via `imports` to extend its functionality.
NixOS provides two options into which such modules can be plugged:
- `system.services.<name>`
- an option for user services (TBD)
Crucially, these options have the type [`attrsOf`] [`submodule`].
The name of the service is the attribute name corresponding to `attrsOf`.
<!-- ^ This is how composition is *always* provided, instead of a difficult thing (but this is reference docs, not a changelog) -->
The `submodule` is pre-loaded with two modules:
- a generic module that is intended to be portable
- a module with systemd-specific options, whose values or defaults derive from the generic module's option values.
So note that the default value of `system.services.<name>` is not a complete service. It requires that the user provide a value, and this is typically done by importing a module. For example:
<!-- Not using typical example syntax, because reading this is *not* optional, and should it should not be folded closed. -->
```nix
{
system.services.my-service-instance = {
imports = [ pkgs.some-application.services.some-service-module ];
foo.settings = {
# ...
};
};
}
```
## Portability {#modular-service-portability}
It is possible to write service modules that are portable. This is done by either avoiding the `systemd` option tree, or by defining process-manager-specific definitions in an optional way:
```nix
{ config, options, lib, ... }: {
_class = "service";
config = {
process.argv = [ (lib.getExe config.foo.program) ];
} // lib.optionalAttrs (options?systemd) {
# ... systemd-specific definitions ...
};
}
```
This way, the module can be loaded into a configuration manager that does not use systemd, and the `systemd` definitions will be ignored.
Similarly, other configuration managers can declare their own options for services to customize.
## Composition and Ownership {#modular-service-composition}
Compared to traditional services, modular services are inherently more composable, by virtue of being modules and receiving a user-provided name when imported.
However, composition can not end there, because services need to be able to interact with each other.
This can be achieved in two ways:
1. Users can link services together by providing the necessary NixOS configuration.
2. Services can be compositions of other services.
These aren't mutually exclusive. In fact, it is a good practice when developing services to first write them as individual services, and then compose them into a higher-level composition. Each of these services is a valid modular service, including their composition.
## Migration {#modular-service-migration}
Many services could be migrated to the modular service system, but even when the modular service system is mature, it is not necessary to migrate all services.
For instance, many system-wide services are a mandatory part of a desktop system, and it doesn't make sense to have multiple instances of them.
Moving their logic into separate Nix files may still be beneficial for the efficient evaluation of configurations that don't use those services, but that is a rather minor benefit, unless modular services potentially become the standard way to define services.
<!-- TODO example of a single-instance service -->
## Portable Service Options {#modular-service-options-portable}
```{=include=} options
id-prefix: service-opt-
list-id: service-options
source: @PORTABLE_SERVICE_OPTIONS@
```
## Systemd-specific Service Options {#modular-service-options-systemd}
```{=include=} options
id-prefix: systemd-service-opt-
list-id: systemd-service-options
source: @SYSTEMD_SERVICE_OPTIONS@
```
[module]: https://nixos.org/manual/nixpkgs/stable/index.html#module-system
<!-- TODO: more anchors -->
[`attrsOf`]: #sec-option-types-composed
[`submodule`]: #sec-option-types-submodule

View File

@@ -11,6 +11,24 @@
"book-nixos-manual": [ "book-nixos-manual": [
"index.html#book-nixos-manual" "index.html#book-nixos-manual"
], ],
"modular-service-composition": [
"index.html#modular-service-composition"
],
"modular-service-migration": [
"index.html#modular-service-migration"
],
"modular-service-options-portable": [
"index.html#modular-service-options-portable"
],
"modular-service-options-systemd": [
"index.html#modular-service-options-systemd"
],
"modular-service-portability": [
"index.html#modular-service-portability"
],
"modular-services": [
"index.html#modular-services"
],
"module-services-anubis": [ "module-services-anubis": [
"index.html#module-services-anubis" "index.html#module-services-anubis"
], ],

View File

@@ -32,5 +32,7 @@
}; };
}; };
# impl of assertions is in <nixpkgs/nixos/modules/system/activation/top-level.nix> # impl of assertions is in
# - <nixpkgs/nixos/modules/system/activation/top-level.nix>
# - <nixpkgs/nixos/modules/system/service/portable/lib.nix>
} }

View File

@@ -1843,6 +1843,8 @@
./system/boot/uvesafb.nix ./system/boot/uvesafb.nix
./system/boot/zram-as-tmp.nix ./system/boot/zram-as-tmp.nix
./system/etc/etc-activation.nix ./system/etc/etc-activation.nix
./system/service/systemd/system.nix
./system/service/systemd/user.nix
./tasks/auto-upgrade.nix ./tasks/auto-upgrade.nix
./tasks/bcache.nix ./tasks/bcache.nix
./tasks/cpu-freq.nix ./tasks/cpu-freq.nix

View File

@@ -0,0 +1,28 @@
# Modular Services
This directory defines a modular service infrastructure for NixOS.
See the [Modular Services chapter] in the manual [[source]](../../doc/manual/development/modular-services.md).
[Modular Services chapter]: https://nixos.org/manual/nixos/unstable/#modular-services
# Design decision log
- `system.services.<name>`. Alternatives considered
- `systemServices`: similar to does not allow importing a composition of services into `system`. Not sure if that's a good idea in the first place, but I've kept the possibility open.
- `services.abstract`: used in https://github.com/NixOS/nixpkgs/pull/267111, but too weird. Service modules should fit naturally into the configuration system.
Also "abstract" is wrong, because it has submodules - in other words, evalModules results, concrete services - not abstract at all.
- `services.modular`: only slightly better than `services.abstract`, but still weird
- No `daemon.*` options. https://github.com/NixOS/nixpkgs/pull/267111/files#r1723206521
- For now, do not add an `enable` option, because it's ambiguous. Does it disable at the Nix level (not generate anything) or at the systemd level (generate a service that is disabled)?
- Move all process options into a `process` option tree. Putting this at the root is messy, because we also have sub-services at that level. Those are rather distinct. Grouping them "by kind" should raise fewer questions.
- `modules/system/service/systemd/system.nix` has `system` twice. Not great, but
- they have different meanings
1. These are system-provided modules, provided by the configuration manager
2. `systemd/system` configures SystemD _system units_.
- This reserves `modules/service` for actual service modules, at least until those are lifted out of NixOS, potentially

View File

@@ -0,0 +1,33 @@
{ lib, ... }:
let
inherit (lib) concatLists mapAttrsToList showOption;
in
rec {
flattenMapServicesConfigToList =
f: loc: config:
f loc config
++ concatLists (
mapAttrsToList (
k: v:
flattenMapServicesConfigToList f (
loc
++ [
"services"
k
]
) v
) config.services
);
getWarnings = flattenMapServicesConfigToList (
loc: config: map (msg: "in ${showOption loc}: ${msg}") config.warnings
);
getAssertions = flattenMapServicesConfigToList (
loc: config:
map (ass: {
message = "in ${showOption loc}: ${ass.message}";
assertion = ass.assertion;
}) config.assertions
);
}

View File

@@ -0,0 +1,48 @@
{
lib,
config,
options,
...
}:
let
inherit (lib) mkOption types;
pathOrStr = types.coercedTo types.path (x: "${x}") types.str;
in
{
# https://nixos.org/manual/nixos/unstable/#modular-services
_class = "service";
imports = [
../../../misc/assertions.nix
];
options = {
services = mkOption {
type = types.attrsOf (
types.submoduleWith {
modules = [
./service.nix
];
}
);
description = ''
A collection of [modular services](https://nixos.org/manual/nixos/unstable/#modular-services) that are configured in one go.
You could consider the sub-service relationship to be an ownership relation.
It **does not** automatically create any other relationship between services (e.g. systemd slices), unless perhaps such a behavior is explicitly defined and enabled in another option.
'';
default = { };
visible = "shallow";
};
process = {
argv = lib.mkOption {
type = types.listOf pathOrStr;
example = lib.literalExpression ''[ (lib.getExe config.package) "--nobackground" ]'';
description = ''
Command filename and arguments for starting this service.
This is a raw command-line that should not contain any shell escaping.
If expansion of environmental variables is required then use
a shell script or `importas` from `pkgs.execline`.
'';
};
};
};
}

View File

@@ -0,0 +1,183 @@
# Run:
# nix-instantiate --eval nixos/modules/system/service/portable/test.nix
let
lib = import ../../../../../lib;
inherit (lib) mkOption types;
portable-lib = import ./lib.nix { inherit lib; };
dummyPkg =
name:
derivation {
system = "dummy";
name = name;
builder = "/bin/false";
};
exampleConfig = {
_file = "${__curPos.file}:${toString __curPos.line}";
services = {
service1 = {
process = {
argv = [
"/usr/bin/echo" # *giggles*
"hello"
];
};
assertions = [
{
assertion = false;
message = "you can't enable this for that reason";
}
];
warnings = [
"The `foo' service is deprecated and will go away soon!"
];
};
service2 = {
process = {
# No meta.mainProgram, because it's supposedly an executable script _file_,
# not a directory with a bin directory containing the main program.
argv = [
(dummyPkg "cowsay.sh")
"world"
];
};
};
service3 = {
process = {
argv = [ "/bin/false" ];
};
services.exclacow = {
process = {
argv = [
(lib.getExe (
dummyPkg "cowsay-ng"
// {
meta.mainProgram = "cowsay";
}
))
"!"
];
};
assertions = [
{
assertion = false;
message = "you can't enable this for such reason";
}
];
warnings = [
"The `bar' service is deprecated and will go away soon!"
];
};
};
};
};
exampleEval = lib.evalModules {
modules = [
{
options.services = mkOption {
type = types.attrsOf (
types.submoduleWith {
class = "service";
modules = [
./service.nix
];
}
);
};
}
exampleConfig
];
};
test =
assert
exampleEval.config == {
services = {
service1 = {
process = {
argv = [
"/usr/bin/echo"
"hello"
];
};
services = { };
assertions = [
{
assertion = false;
message = "you can't enable this for that reason";
}
];
warnings = [
"The `foo' service is deprecated and will go away soon!"
];
};
service2 = {
process = {
argv = [
"${dummyPkg "cowsay.sh"}"
"world"
];
};
services = { };
assertions = [ ];
warnings = [ ];
};
service3 = {
process = {
argv = [ "/bin/false" ];
};
services.exclacow = {
process = {
argv = [
"${dummyPkg "cowsay-ng"}/bin/cowsay"
"!"
];
};
services = { };
assertions = [
{
assertion = false;
message = "you can't enable this for such reason";
}
];
warnings = [ "The `bar' service is deprecated and will go away soon!" ];
};
assertions = [ ];
warnings = [ ];
};
};
};
assert
portable-lib.getWarnings [ "service1" ] exampleEval.config.services.service1 == [
"in service1: The `foo' service is deprecated and will go away soon!"
];
assert
portable-lib.getAssertions [ "service1" ] exampleEval.config.services.service1 == [
{
message = "in service1: you can't enable this for that reason";
assertion = false;
}
];
assert
portable-lib.getWarnings [ "service3" ] exampleEval.config.services.service3 == [
"in service3.services.exclacow: The `bar' service is deprecated and will go away soon!"
];
assert
portable-lib.getAssertions [ "service3" ] exampleEval.config.services.service3 == [
{
message = "in service3.services.exclacow: you can't enable this for such reason";
assertion = false;
}
];
"ok";
in
test

View File

@@ -0,0 +1,121 @@
{
lib,
config,
systemdPackage,
...
}:
let
inherit (lib)
concatMapStringsSep
isDerivation
isInt
isFloat
isPath
isString
mkOption
replaceStrings
types
;
inherit (builtins) toJSON;
# Local copy of systemd exec argument escaping function.
# TODO: This could perhaps be deduplicated, but it is unclear where it should go.
# Preferably, we don't create a hard dependency on NixOS here, so that this
# module can be reused in a non-NixOS context, such as mutaable services
# in /run/systemd/system.
# 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 isPath arg then
"${arg}"
else if isString arg then
arg
else if isInt arg || isFloat arg || isDerivation arg then
toString arg
else
throw "escapeSystemdExecArg only allows strings, paths, numbers and derivations";
in
replaceStrings [ "%" "$" ] [ "%%" "$$" ] (toJSON s);
# Quotes a list of arguments into a single string for use in a Exec*
# line.
escapeSystemdExecArgs = concatMapStringsSep " " escapeSystemdExecArg;
in
{
imports = [
../portable/service.nix
(lib.mkAliasOptionModule [ "systemd" "service" ] [ "systemd" "services" "" ])
(lib.mkAliasOptionModule [ "systemd" "socket" ] [ "systemd" "sockets" "" ])
];
options = {
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.
This option's value is not suitable for reading, but you can define a module here that interacts with just the unit configuration in the host system configuration.
Note that this option contains _deferred_ modules.
This means that the module has not been combined with the system configuration yet, no values can be read from this option.
What you can do instead is define a module that reads from the module arguments (such as `config`) that are available when the module is merged into the system configuration.
'';
type = types.lazyAttrsOf (
types.deferredModuleWith {
staticModules = [
# TODO: Add modules for the purpose of generating documentation?
];
}
);
default = { };
};
systemd.sockets = mkOption {
description = ''
Declares systemd socket units. Names will be prefixed by the service name / path.
See {option}`systemd.services`.
'';
type = types.lazyAttrsOf types.deferredModule;
default = { };
};
# Also import systemd logic into sub-services
# extends the portable `services` option
services = mkOption {
type = types.attrsOf (
types.submoduleWith {
class = "service";
modules = [
./service.nix
];
specialArgs = {
inherit systemdPackage;
};
}
);
};
};
config = {
# Note that this is the systemd.services option above, not the system one.
systemd.services."" = {
# TODO description;
wantedBy = lib.mkDefault [ "multi-user.target" ];
serviceConfig = {
Type = lib.mkDefault "simple";
Restart = lib.mkDefault "always";
RestartSec = lib.mkDefault "5";
ExecStart = [
(escapeSystemdExecArgs config.process.argv)
];
};
};
};
}

View File

@@ -0,0 +1,90 @@
{
lib,
config,
options,
pkgs,
...
}:
let
inherit (lib)
concatMapAttrs
mkOption
types
concatLists
mapAttrsToList
;
portable-lib = import ../portable/lib.nix { inherit lib; };
dash =
before: after:
if after == "" then
before
else if before == "" then
after
else
"${before}-${after}";
makeUnits =
unitType: prefix: service:
concatMapAttrs (unitName: unitModule: {
"${dash prefix unitName}" =
{ ... }:
{
imports = [ unitModule ];
};
}) service.systemd.${unitType}
// concatMapAttrs (
subServiceName: subService: makeUnits unitType (dash prefix subServiceName) subService
) service.services;
in
{
# First half of the magic: mix systemd logic into the otherwise abstract services
options = {
system.services = mkOption {
description = ''
A collection of NixOS [modular services](https://nixos.org/manual/nixos/unstable/#modular-services) that are configured as systemd services.
'';
type = types.attrsOf (
types.submoduleWith {
class = "service";
modules = [
./service.nix
];
specialArgs = {
# perhaps: features."systemd" = { };
inherit pkgs;
systemdPackage = config.systemd.package;
};
}
);
default = { };
visible = "shallow";
};
};
# Second half of the magic: siphon units that were defined in isolation to the system
config = {
assertions = concatLists (
mapAttrsToList (
name: cfg: portable-lib.getAssertions (options.system.services.loc ++ [ name ]) cfg
) config.system.services
);
warnings = concatLists (
mapAttrsToList (
name: cfg: portable-lib.getWarnings (options.system.services.loc ++ [ name ]) cfg
) config.system.services
);
systemd.services = concatMapAttrs (
serviceName: topLevelService: makeUnits "services" serviceName topLevelService
) config.system.services;
systemd.sockets = concatMapAttrs (
serviceName: topLevelService: makeUnits "sockets" serviceName topLevelService
) config.system.services;
};
}

View File

@@ -0,0 +1,92 @@
# Run:
# nix-build -A nixosTests.modularService
{
evalSystem,
runCommand,
hello,
...
}:
let
machine = evalSystem (
{ lib, ... }:
let
hello' = lib.getExe hello;
in
{
# Test input
system.services.foo = {
process = {
argv = [
hello'
"--greeting"
"hoi"
];
};
};
system.services.bar = {
process = {
argv = [
hello'
"--greeting"
"hoi"
];
};
systemd.service = {
serviceConfig.X-Bar = "lol crossbar whatever";
};
services.db = {
process = {
argv = [
hello'
"--greeting"
"Hi, I'm a database, would you believe it"
];
};
systemd.service = {
serviceConfig.RestartSec = "42";
};
};
};
# irrelevant stuff
system.stateVersion = "25.05";
fileSystems."/".device = "/test/dummy";
boot.loader.grub.enable = false;
}
);
inherit (machine.config.system.build) toplevel;
in
runCommand "test-modular-service-systemd-units"
{
passthru = {
inherit
machine
toplevel
;
};
}
''
echo ${toplevel}/etc/systemd/system/foo.service:
cat -n ${toplevel}/etc/systemd/system/foo.service
(
set -x
grep -F 'ExecStart="${hello}/bin/hello" "--greeting" "hoi"' ${toplevel}/etc/systemd/system/foo.service >/dev/null
grep -F 'ExecStart="${hello}/bin/hello" "--greeting" "hoi"' ${toplevel}/etc/systemd/system/bar.service >/dev/null
grep -F 'X-Bar=lol crossbar whatever' ${toplevel}/etc/systemd/system/bar.service >/dev/null
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
[[ ! -e ${toplevel}/etc/systemd/system/foo.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar.socket ]]
[[ ! -e ${toplevel}/etc/systemd/system/bar-db.socket ]]
)
echo 🐬👍
touch $out
''

View File

@@ -0,0 +1,3 @@
# TBD, analogous to system.nix but for user units
{
}

View File

@@ -89,6 +89,16 @@ let
featureFlags.minimalModules = { }; featureFlags.minimalModules = { };
}; };
evalMinimalConfig = module: nixosLib.evalModules { modules = [ module ]; }; evalMinimalConfig = module: nixosLib.evalModules { modules = [ module ]; };
evalSystem =
module:
import ../lib/eval-config.nix {
system = null;
modules = [
../modules/misc/nixpkgs/read-only.nix
{ nixpkgs.pkgs = pkgs; }
module
];
};
inherit inherit
(rec { (rec {
@@ -589,6 +599,7 @@ in
gerrit = runTest ./gerrit.nix; gerrit = runTest ./gerrit.nix;
geth = runTest ./geth.nix; geth = runTest ./geth.nix;
ghostunnel = runTest ./ghostunnel.nix; ghostunnel = runTest ./ghostunnel.nix;
ghostunnel-modular = runTest ./ghostunnel-modular.nix;
gitdaemon = runTest ./gitdaemon.nix; gitdaemon = runTest ./gitdaemon.nix;
gitea = handleTest ./gitea.nix { giteaPackage = pkgs.gitea; }; gitea = handleTest ./gitea.nix { giteaPackage = pkgs.gitea; };
github-runner = runTest ./github-runner.nix; github-runner = runTest ./github-runner.nix;
@@ -894,6 +905,9 @@ in
mjolnir = runTest ./matrix/mjolnir.nix; mjolnir = runTest ./matrix/mjolnir.nix;
mobilizon = runTest ./mobilizon.nix; mobilizon = runTest ./mobilizon.nix;
mod_perl = runTest ./mod_perl.nix; mod_perl = runTest ./mod_perl.nix;
modularService = pkgs.callPackage ../modules/system/service/systemd/test.nix {
inherit evalSystem;
};
molly-brown = runTest ./molly-brown.nix; molly-brown = runTest ./molly-brown.nix;
mollysocket = runTest ./mollysocket.nix; mollysocket = runTest ./mollysocket.nix;
monado = runTest ./monado.nix; monado = runTest ./monado.nix;

View File

@@ -0,0 +1,120 @@
{ hostPkgs, lib, ... }:
{
_class = "nixosTest";
name = "ghostunnel";
nodes = {
backend =
{ pkgs, ... }:
{
services.nginx.enable = true;
services.nginx.virtualHosts."backend".root = pkgs.runCommand "webroot" { } ''
mkdir $out
echo hi >$out/hi.txt
'';
networking.firewall.allowedTCPPorts = [ 80 ];
};
service =
{ pkgs, ... }:
{
system.services."ghostunnel-plain-old" = {
imports = [ pkgs.ghostunnel.services.default ];
ghostunnel = {
listen = "0.0.0.0:443";
cert = "/root/service-cert.pem";
key = "/root/service-key.pem";
disableAuthentication = true;
target = "backend:80";
unsafeTarget = true;
};
};
system.services."ghostunnel-client-cert" = {
imports = [ pkgs.ghostunnel.services.default ];
ghostunnel = {
listen = "0.0.0.0:1443";
cert = "/root/service-cert.pem";
key = "/root/service-key.pem";
cacert = "/root/ca.pem";
target = "backend:80";
allowCN = [ "client" ];
unsafeTarget = true;
};
};
networking.firewall.allowedTCPPorts = [
443
1443
];
};
client =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.curl
];
};
};
testScript = ''
# prepare certificates
def cmd(command):
print(f"+{command}")
r = os.system(command)
if r != 0:
raise Exception(f"Command {command} failed with exit code {r}")
# Create CA
cmd("${hostPkgs.openssl}/bin/openssl genrsa -out ca-key.pem 4096")
cmd("${hostPkgs.openssl}/bin/openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj '/C=NL/ST=Zuid-Holland/L=The Hague/O=Stevige Balken en Planken B.V./OU=OpSec/CN=Certificate Authority' -out ca.pem")
# Create service
cmd("${hostPkgs.openssl}/bin/openssl genrsa -out service-key.pem 4096")
cmd("${hostPkgs.openssl}/bin/openssl req -subj '/CN=service' -sha256 -new -key service-key.pem -out service.csr")
cmd("echo subjectAltName = DNS:service,IP:127.0.0.1 >> extfile.cnf")
cmd("echo extendedKeyUsage = serverAuth >> extfile.cnf")
cmd("${hostPkgs.openssl}/bin/openssl x509 -req -days 365 -sha256 -in service.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out service-cert.pem -extfile extfile.cnf")
# Create client
cmd("${hostPkgs.openssl}/bin/openssl genrsa -out client-key.pem 4096")
cmd("${hostPkgs.openssl}/bin/openssl req -subj '/CN=client' -new -key client-key.pem -out client.csr")
cmd("echo extendedKeyUsage = clientAuth > extfile-client.cnf")
cmd("${hostPkgs.openssl}/bin/openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -extfile extfile-client.cnf")
cmd("ls -al")
start_all()
# Configuration
service.copy_from_host("ca.pem", "/root/ca.pem")
service.copy_from_host("service-cert.pem", "/root/service-cert.pem")
service.copy_from_host("service-key.pem", "/root/service-key.pem")
client.copy_from_host("ca.pem", "/root/ca.pem")
client.copy_from_host("service-cert.pem", "/root/service-cert.pem")
client.copy_from_host("client-cert.pem", "/root/client-cert.pem")
client.copy_from_host("client-key.pem", "/root/client-key.pem")
backend.wait_for_unit("nginx.service")
service.wait_for_unit("multi-user.target")
service.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
# Check assumptions before the real test
client.succeed("bash -c 'diff <(curl -v --no-progress-meter http://backend/hi.txt) <(echo hi)'")
# Plain old simple TLS can connect, ignoring cert
client.succeed("bash -c 'diff <(curl -v --no-progress-meter --insecure https://service/hi.txt) <(echo hi)'")
# Plain old simple TLS provides correct signature with its cert
client.succeed("bash -c 'diff <(curl -v --no-progress-meter --cacert /root/ca.pem https://service/hi.txt) <(echo hi)'")
# Client can authenticate with certificate
client.succeed("bash -c 'diff <(curl -v --no-progress-meter --cert /root/client-cert.pem --key /root/client-key.pem --cacert /root/ca.pem https://service:1443/hi.txt) <(echo hi)'")
# Client must authenticate with certificate
client.fail("bash -c 'diff <(curl -v --no-progress-meter --cacert /root/ca.pem https://service:1443/hi.txt) <(echo hi)'")
'';
meta.maintainers = with lib.maintainers; [
roberth
];
}

View File

@@ -4,6 +4,7 @@
fetchFromGitHub, fetchFromGitHub,
lib, lib,
nixosTests, nixosTests,
ghostunnel,
apple-sdk_12, apple-sdk_12,
darwinMinVersionHook, darwinMinVersionHook,
}: }:
@@ -39,6 +40,11 @@ buildGoModule rec {
podman = nixosTests.podman-tls-ghostunnel; podman = nixosTests.podman-tls-ghostunnel;
}; };
passthru.services.default = {
imports = [ ./service.nix ];
ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage
};
meta = { meta = {
description = "TLS proxy with mutual authentication support for securing non-TLS backend applications"; description = "TLS proxy with mutual authentication support for securing non-TLS backend applications";
homepage = "https://github.com/ghostunnel/ghostunnel#readme"; homepage = "https://github.com/ghostunnel/ghostunnel#readme";

View File

@@ -0,0 +1,241 @@
{
lib,
config,
options,
pkgs,
...
}:
let
inherit (lib)
concatStringsSep
getExe
mkDefault
mkIf
mkOption
optional
types
;
cfg = config.ghostunnel;
in
{
# https://nixos.org/manual/nixos/unstable/#modular-services
_class = "service";
options = {
ghostunnel = {
package = mkOption {
description = "Package to use for ghostunnel";
type = types.package;
};
listen = mkOption {
description = ''
Address and port to listen on (can be HOST:PORT, unix:PATH).
'';
type = types.str;
};
target = mkOption {
description = ''
Address to forward connections to (can be HOST:PORT or unix:PATH).
'';
type = types.str;
};
keystore = mkOption {
description = ''
Path to keystore (combined PEM with cert/key, or PKCS12 keystore).
NB: storepass is not supported because it would expose credentials via `/proc/*/cmdline`.
Specify this or `cert` and `key`.
'';
type = types.nullOr types.str;
default = null;
};
cert = mkOption {
description = ''
Path to certificate (PEM with certificate chain).
Not required if `keystore` is set.
'';
type = types.nullOr types.str;
default = null;
};
key = mkOption {
description = ''
Path to certificate private key (PEM with private key).
Not required if `keystore` is set.
'';
type = types.nullOr types.str;
default = null;
};
cacert = mkOption {
description = ''
Path to CA bundle file (PEM/X509). Uses system trust store if `null`.
'';
type = types.nullOr types.str;
};
disableAuthentication = mkOption {
description = ''
Disable client authentication, no client certificate will be required.
'';
type = types.bool;
default = false;
};
allowAll = mkOption {
description = ''
If true, allow all clients, do not check client cert subject.
'';
type = types.bool;
default = false;
};
allowCN = mkOption {
description = ''
Allow client if common name appears in the list.
'';
type = types.listOf types.str;
default = [ ];
};
allowOU = mkOption {
description = ''
Allow client if organizational unit name appears in the list.
'';
type = types.listOf types.str;
default = [ ];
};
allowDNS = mkOption {
description = ''
Allow client if DNS subject alternative name appears in the list.
'';
type = types.listOf types.str;
default = [ ];
};
allowURI = mkOption {
description = ''
Allow client if URI subject alternative name appears in the list.
'';
type = types.listOf types.str;
default = [ ];
};
extraArguments = mkOption {
description = "Extra arguments to pass to `ghostunnel server`";
type = types.listOf types.str;
default = [ ];
};
unsafeTarget = mkOption {
description = ''
If set, does not limit target to localhost, 127.0.0.1, [::1], or UNIX sockets.
This is meant to protect against accidental unencrypted traffic on
untrusted networks.
'';
type = types.bool;
default = false;
};
};
};
config = {
assertions = [
{
message = ''
At least one access control flag is required.
Set at least one of:
- ${options.ghostunnel.disableAuthentication}
- ${options.ghostunnel.allowAll}
- ${options.ghostunnel.allowCN}
- ${options.ghostunnel.allowOU}
- ${options.ghostunnel.allowDNS}
- ${options.ghostunnel.allowURI}
'';
assertion =
cfg.disableAuthentication
|| cfg.allowAll
|| cfg.allowCN != [ ]
|| cfg.allowOU != [ ]
|| cfg.allowDNS != [ ]
|| cfg.allowURI != [ ];
}
];
ghostunnel = {
# Clients should not be authenticated with the public root certificates
# (afaict, it doesn't make sense), so we only provide that default when
# client cert auth is disabled.
cacert = mkIf cfg.disableAuthentication (mkDefault null);
};
# 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
])
(
pkgs.writeScript "load-credentials" ''
#!${pkgs.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;
};
# 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}";
};
};
};
}

View File

@@ -94,7 +94,7 @@ class Redirects:
- The first element of an identifier's redirects list must denote its current location. - The first element of an identifier's redirects list must denote its current location.
""" """
xref_targets = {} xref_targets = {}
ignored_identifier_patterns = ("opt-", "auto-generated-", "function-library-") ignored_identifier_patterns = ("opt-", "auto-generated-", "function-library-", "service-opt-", "systemd-service-opt")
for id, target in initial_xref_targets.items(): for id, target in initial_xref_targets.items():
# filter out automatically generated identifiers from module options and library documentation # filter out automatically generated identifiers from module options and library documentation
if id.startswith(ignored_identifier_patterns): if id.startswith(ignored_identifier_patterns):