From e5d8795180eb78becd41a32d2ff78b24f5f459f0 Mon Sep 17 00:00:00 2001 From: Arian van Putten Date: Sun, 18 Jan 2026 16:45:16 +0100 Subject: [PATCH] nixos/spire: init --- nixos/modules/module-list.nix | 2 + .../modules/services/security/spire/agent.nix | 123 ++++++++++++++++++ .../services/security/spire/server.nix | 120 +++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/spire.nix | 99 ++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 nixos/modules/services/security/spire/agent.nix create mode 100644 nixos/modules/services/security/spire/server.nix create mode 100644 nixos/tests/spire.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 5903916b62a7..fde4180a04a6 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1515,6 +1515,8 @@ ./services/security/reaction.nix ./services/security/shibboleth-sp.nix ./services/security/sks.nix + ./services/security/spire/agent.nix + ./services/security/spire/server.nix ./services/security/ssh-agent-switcher.nix ./services/security/sshguard.nix ./services/security/sslmate-agent.nix diff --git a/nixos/modules/services/security/spire/agent.nix b/nixos/modules/services/security/spire/agent.nix new file mode 100644 index 000000000000..f66c0ff5a970 --- /dev/null +++ b/nixos/modules/services/security/spire/agent.nix @@ -0,0 +1,123 @@ +{ + lib, + pkgs, + config, + ... +}: +let + format = pkgs.formats.hcl1 { }; + cfg = config.services.spire.agent; +in +{ + meta.maintainers = [ lib.maintainers.arianvp ]; + + options.services.spire.agent = { + enable = lib.mkEnableOption "SPIRE agent"; + + package = lib.mkPackageOption pkgs "spire" { }; + + settings = lib.mkOption { + description = '' + SPIRE Agent configuration file options. See [the documentation](https://spiffe.io/docs/latest/deploying/spire_agent/) for all available options. + ''; + type = lib.types.submodule { + freeformType = format.type; + options = { + agent = { + trust_domain = lib.mkOption { + type = lib.types.str; + description = "The trust domain that this agent belongs to"; + example = "example.com"; + }; + data_dir = lib.mkOption { + type = lib.types.str; + default = "$STATE_DIRECTORY"; + description = "The directory where the SPIRE agent stores its data"; + }; + server_address = lib.mkOption { + type = lib.types.str; + description = "The address of the SPIRE server"; + example = "server.example.com"; + }; + server_port = lib.mkOption { + type = lib.types.port; + default = 8081; + description = "The port on which the SPIRE server is listening"; + }; + socket_path = lib.mkOption { + type = lib.types.path; + default = "/run/spire/agent/public/api.sock"; + description = "The path to the SPIRE agent socket"; + }; + }; + plugins = lib.mkOption { + description = '' + Built-in plugin types can be found at [the plugin types documentation](https://spiffe.io/docs/latest/deploying/spire_agent/#plugin-types). + See [plugin configuration](https://spiffe.io/docs/latest/deploying/spire_agent/#plugin-configuration) for options and how to configure external plugins. + ''; + # TODO: We can probably enforce some of these constraints with a submodule + type = format.type; + example = { + KeyManager.memory.plugin_data = { }; + NodeAttestor.join_token.plugin_data = { }; + WorkloadAttestor.systemd.plugin_data = { }; + WorkloadAttestor.unix.plugin_data = { }; + }; + }; + }; + }; + }; + + configFile = lib.mkOption { + type = lib.types.path; + defaultText = "Config file generated from services.spire.agent.settings"; + default = format.generate "agent.conf" cfg.settings; + description = '' + Path to the SPIRE agent configuration file. See [the documentation](https://spiffe.io/docs/latest/deploying/spire_agent/) for more information. + ''; + }; + + expandEnv = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Expand environment variables in SPIRE config file"; + }; + + }; + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # TODO: Switch to DynamicUser once https://github.com/NixOS/nixpkgs/issues/299476 lands + users.users.spire-agent = { + isSystemUser = true; + group = "spire-agent"; + }; + users.groups.spire-agent = { }; + + systemd.services.spire-agent = { + wantedBy = [ "multi-user.target" ]; + description = "SPIRE agent"; + serviceConfig = { + ExecStart = + "${lib.getExe' cfg.package "spire-agent"} run " + + lib.cli.toCommandLineShellGNU { } { + inherit (cfg) expandEnv; + config = cfg.configFile; + }; + Restart = "on-failure"; + StateDirectory = "spire/agent"; + StateDirectoryMode = "0700"; + RuntimeDirectory = "spire/agent"; + + # TODO: Switch to DynamicUser once https://github.com/NixOS/nixpkgs/issues/299476 lands + # Without it, the systemd plugin can not talk to dbus + # DynamicUser = true; + User = "spire-agent"; + Group = "spire-agent"; + UMask = "0027"; + + # TODO: Hardening + }; + }; + }; +} diff --git a/nixos/modules/services/security/spire/server.nix b/nixos/modules/services/security/spire/server.nix new file mode 100644 index 000000000000..44025d77e50b --- /dev/null +++ b/nixos/modules/services/security/spire/server.nix @@ -0,0 +1,120 @@ +{ + lib, + pkgs, + config, + ... +}: +let + format = pkgs.formats.hcl1 { }; + cfg = config.services.spire.server; +in +{ + meta.maintainers = [ lib.maintainers.arianvp ]; + + options.services.spire.server = { + enable = lib.mkEnableOption "SPIRE Server"; + + openFirewall = lib.mkOption { + type = lib.types.bool; + description = "Whether to open firewall"; + default = false; + }; + + settings = lib.mkOption { + description = '' + SPIRE Server configuration file options. See [the documentation](https://spiffe.io/docs/latest/deploying/spire_server/) for all available options. + ''; + type = lib.types.submodule { + freeformType = format.type; + options = { + server = { + trust_domain = lib.mkOption { + type = lib.types.str; + description = "The trust domain that this server belongs to"; + example = "example.com"; + }; + data_dir = lib.mkOption { + type = lib.types.str; + description = "The directory where SPIRE server stores its data"; + default = "$STATE_DIRECTORY"; + }; + socket_path = lib.mkOption { + type = lib.types.str; + default = "/run/spire/server/private/api.sock"; + description = "Path to bind the SPIRE Server API Socket to"; + }; + bind_address = lib.mkOption { + type = lib.types.str; + default = "[::]"; + description = "The address on which the SPIRE server is listening"; + }; + bind_port = lib.mkOption { + type = lib.types.port; + default = 8081; + description = "The port on which the SPIRE server is listening"; + }; + }; + plugins = lib.mkOption { + description = '' + Built-in plugin types can be found at [the plugin types documentation](https://spiffe.io/docs/latest/deploying/spire_server/#plugin-types). + See [plugin configuration](https://spiffe.io/docs/latest/deploying/spire_server/#plugin-configuration) for options and how to configure external plugins. + ''; + # TODO: We can probably enforce some of these constraints with a submodule + type = format.type; + example = { + KeyManager.memory.plugin_data = { }; + DataStore.sql.plugin_data = { + database_type = "sqlite3"; + connection_string = "$STATE_DIRECTORY/datastore.sqlite3"; + }; + NodeAttestor.join_token.plugin_data = { }; + }; + }; + }; + }; + }; + + configFile = lib.mkOption { + type = lib.types.path; + default = format.generate "server.conf" cfg.settings; + defaultText = "Config file generated from services.spire.server.settings"; + description = '' + Path to the SPIRE server configuration file. See [the documentation](https://spiffe.io/docs/latest/deploying/spire_server/) for more information. + ''; + }; + + expandEnv = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Expand environment variables in services.spire.server.settings and services.spire.server.configFile"; + }; + + package = lib.mkPackageOption pkgs "spire" { }; + + }; + + config = lib.mkIf cfg.enable { + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.settings.server.bind_port ]; + environment.systemPackages = [ cfg.package ]; + systemd.services.spire-server = { + wantedBy = [ "multi-user.target" ]; + description = "SPIRE Server"; + documentation = [ "https://spiffe.io/docs/latest/deploying/spire_server/" ]; + serviceConfig = { + ExecStart = + "${lib.getExe' cfg.package "spire-server"} run " + + lib.cli.toCommandLineShellGNU { } { + inherit (cfg) expandEnv; + config = cfg.configFile; + }; + Restart = "on-failure"; + StateDirectory = "spire/server"; + StateDirectoryMode = "0700"; + RuntimeDirectory = "spire/server"; + DynamicUser = true; + UMask = "0027"; + # TODO: hardening + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 41f3651e0554..34cc5e601125 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1490,6 +1490,7 @@ in spacecookie = runTest ./spacecookie.nix; spark = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./spark { }; spiped = runTest ./spiped.nix; + spire = runTest ./spire.nix; sqlite3-to-mysql = runTest ./sqlite3-to-mysql.nix; squid = runTest ./squid.nix; ssh-agent-auth = runTest ./ssh-agent-auth.nix; diff --git a/nixos/tests/spire.nix b/nixos/tests/spire.nix new file mode 100644 index 000000000000..52120558a264 --- /dev/null +++ b/nixos/tests/spire.nix @@ -0,0 +1,99 @@ +let + trustDomain = "example.com"; +in +{ + name = "spire"; + + nodes = { + server = + { config, ... }: + { + networking.domain = trustDomain; + services.spire.server = { + enable = true; + openFirewall = true; + settings = { + server.trust_domain = trustDomain; + plugins = { + KeyManager.memory.plugin_data = { }; + DataStore.sql.plugin_data = { + database_type = "sqlite3"; + connection_string = "$STATE_DIRECTORY/datastore.sqlite3"; + }; + NodeAttestor.join_token.plugin_data = { }; + }; + }; + }; + }; + + agent = { + virtualisation.credentials = { + "spire.join_token".source = "./join_token"; + "spire.trust_bundle".source = "./trust_bundle"; + }; + + systemd.services.spire-agent.serviceConfig.ImportCredential = [ + "spire.join_token" + "spire.trust_bundle" + ]; + + services.spire.agent = { + enable = true; + settings = { + agent = { + trust_domain = trustDomain; + server_address = "server.${trustDomain}"; + join_token_file = "$CREDENTIALS_DIRECTORY/spire.join_token"; + trust_bundle_format = "pem"; + trust_bundle_path = "$CREDENTIALS_DIRECTORY/spire.trust_bundle"; + }; + plugins = { + KeyManager.memory.plugin_data = { }; + NodeAttestor.join_token.plugin_data = { }; + WorkloadAttestor.systemd.plugin_data = { }; + WorkloadAttestor.unix.plugin_data = { }; + }; + }; + }; + }; + }; + + testScript = + { nodes }: + let + adminSocket = nodes.server.services.spire.server.settings.server.socket_path; + workloadSocket = nodes.agent.services.spire.agent.settings.agent.socket_path; + in + '' + # TODO: instead of trust bundle to talk to the spire-server, use an upstream CA? + def provision(agent, spiffe_id): + + # expose as system credentials + bundle = server.succeed("spire-server bundle show -socketPath ${adminSocket}") + with open(agent.state_dir / "trust_bundle", "w") as f: + f.write(bundle) + join_token = server.succeed("spire-server token generate -socketPath ${adminSocket}").split()[1] + with open(agent.state_dir / "join_token", "w") as f: + f.write(join_token) + + # register a workload on the node + parent_id=f"spiffe://${trustDomain}/spire/agent/join_token/{join_token}" + server.succeed(f"spire-server entry create -socketPath ${adminSocket} -selector 'systemd:id:backdoor.service' -parentID {parent_id} -spiffeID 'spiffe://${trustDomain}/service/backdoor'") + + with subtest("SPIRE server startup and health checks"): + server.wait_for_unit("spire-server.service") + server.wait_until_succeeds("spire-server healthcheck -socketPath ${adminSocket}", timeout=5) + + + with subtest("Setup SPIRE agent on agent node"): + provision(agent, "spiffe://${trustDomain}/server/agent") + agent.wait_for_unit("spire-agent.service") + agent.wait_until_succeeds("spire-agent healthcheck -socketPath ${workloadSocket}", timeout=5) + + + with subtest("Test certificate authentication from agent node"): + agent.succeed("spire-agent api fetch x509 -socketPath ${workloadSocket} -write .") + + # TODO: Add something to communicate with + ''; +}