diff --git a/CHANGELOG.md b/CHANGELOG.md index a76362d..3b139f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--ssh-accept-host` was added. - `--on -` will now read additional apply targets from stdin. +- `{key.name}-key.{path,service}` systemd units where added. - `--path` now supports flakerefs (`github:foo/bar`, `git+file:///...`, `gitlab:foo/bar`, etc). - `--flake` is now an alias for `--path`. diff --git a/doc/guides/keys.md b/doc/guides/keys.md index 03fafde..e80d7e3 100644 --- a/doc/guides/keys.md +++ b/doc/guides/keys.md @@ -167,6 +167,12 @@ in wire.makeHive { You can access the full absolute path of any key with `config.deployment.keys..path` (auto-generated and read-only). + +Keys also have a `config.deployment.keys..service` property +(auto-generated and read-only), which represent systemd services that you can +`require`, telling systemd there is a hard-dependency on that key for the +service to run. + Here's an example with the Tailscale service: ```nix:line-numbers [hive.nix] @@ -186,6 +192,11 @@ in wire.makeHive { deployment.keys."tailscale.key" = { keyCommand = ["gpg" "--decrypt" "${./secrets/tailscale.key.gpg}"]; }; + + # The service will not start unless the key exists. + systemd.services.tailscaled-autoconnect.requires = [ + config.deployment.keys."tailscale.key".service + ]; }; } ``` diff --git a/doc/options.nix b/doc/options.nix index 4dc4cc9..8f19c64 100644 --- a/doc/options.nix +++ b/doc/options.nix @@ -7,7 +7,7 @@ let eval = lib.evalModules { modules = [ - ../runtime/module.nix + ../runtime/module/options.nix { options._module.args = lib.mkOption { internal = true; diff --git a/doc/package.nix b/doc/package.nix index 12a96cc..e0fbccb 100644 --- a/doc/package.nix +++ b/doc/package.nix @@ -13,7 +13,7 @@ let eval = lib.evalModules { modules = [ - ../runtime/module.nix + ../runtime/module/options.nix { options._module.args = lib.mkOption { internal = true; diff --git a/flake.nix b/flake.nix index 7ed4795..d862de3 100644 --- a/flake.nix +++ b/flake.nix @@ -41,7 +41,7 @@ systems = import systems; flake = { - nixosModules.default = import ./runtime/module.nix; + nixosModules.default = import ./runtime/module; makeHive = import ./runtime/makeHive.nix; hydraJobs = let diff --git a/runtime/evaluate.nix b/runtime/evaluate.nix index 5c84c43..40f5bb6 100644 --- a/runtime/evaluate.nix +++ b/runtime/evaluate.nix @@ -6,7 +6,7 @@ nixosConfigurations ? { }, }: let - module = import ./module.nix; + module = import ./module; mergedHive = { meta = { }; diff --git a/runtime/module/config.nix b/runtime/module/config.nix new file mode 100644 index 0000000..fa9d11c --- /dev/null +++ b/runtime/module/config.nix @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright 2024-2025 wire Contributors + +{ + pkgs, + lib, + config, + ... +}: +{ + config = { + systemd = { + paths = lib.mapAttrs' ( + _name: value: + lib.nameValuePair "${value.name}-key" { + description = "Monitor changes to ${value.path}. You should Require ${value.service} instead of this."; + pathConfig = { + PathExists = value.path; + PathChanged = value.path; + Unit = "${value.name}-key.service"; + }; + } + ) config.deployment.keys; + + services = lib.mapAttrs' ( + _name: value: + lib.nameValuePair "${value.name}-key" { + description = "Service that requires ${value.path}"; + path = [ + pkgs.inotify-tools + pkgs.coreutils + ]; + script = '' + MSG="Key ${value.path} exists." + systemd-notify --ready --status="$MSG" + + echo "waiting to fail if the key is removed..." + + while inotifywait -e delete_self "${value.path}"; do + MSG="Key ${value.path} no longer exists." + + systemd-notify --status="$MSG" + echo $MSG + + exit 1 + done + ''; + unitConfig = { + ConditionPathExists = value.path; + }; + serviceConfig = { + Type = "simple"; + Restart = "no"; + NotifyAccess = "all"; + RemainAfterExit = "yes"; + }; + } + ) config.deployment.keys; + }; + + deployment = { + _keys = lib.mapAttrsToList ( + _: value: + value + // { + source = { + # Attach type to internally tag serde enum + t = builtins.replaceStrings [ "path" "string" "list" ] [ "Path" "String" "Command" ] ( + builtins.typeOf value.source + ); + c = value.source; + }; + } + ) config.deployment.keys; + + _hostPlatform = config.nixpkgs.hostPlatform.system; + }; + }; +} diff --git a/runtime/module/default.nix b/runtime/module/default.nix new file mode 100644 index 0000000..0ebefe4 --- /dev/null +++ b/runtime/module/default.nix @@ -0,0 +1,6 @@ +{ + imports = [ + ./options.nix + ./config.nix + ]; +} diff --git a/runtime/module.nix b/runtime/module/options.nix similarity index 90% rename from runtime/module.nix rename to runtime/module/options.nix index 47b96a0..4e5954f 100644 --- a/runtime/module.nix +++ b/runtime/module/options.nix @@ -4,7 +4,6 @@ { lib, name, - config, ... }: let @@ -115,7 +114,18 @@ in path = lib.mkOption { internal = true; type = types.path; - default = "${config.destDir}/${config.name}"; + default = + if lib.hasSuffix "/" config.destDir then + "${config.destDir}${config.name}" + else + "${config.destDir}/${config.name}"; + description = "Path that the key is deployed to."; + }; + service = lib.mkOption { + internal = true; + type = types.str; + default = "${config.name}-key.service"; + description = "Name of the systemd service that represents this key."; }; group = lib.mkOption { type = types.str; @@ -183,24 +193,4 @@ in }; }; }; - - config = { - deployment = { - _keys = lib.mapAttrsToList ( - _: value: - value - // { - source = { - # Attach type to internally tag serde enum - t = builtins.replaceStrings [ "path" "string" "list" ] [ "Path" "String" "Command" ] ( - builtins.typeOf value.source - ); - c = value.source; - }; - } - ) config.deployment.keys; - - _hostPlatform = config.nixpkgs.hostPlatform.system; - }; - }; } diff --git a/tests/nix/suite/test_keys/default.nix b/tests/nix/suite/test_keys/default.nix index e772e94..93379c7 100644 --- a/tests/nix/suite/test_keys/default.nix +++ b/tests/nix/suite/test_keys/default.nix @@ -14,14 +14,20 @@ deployer_so = collect_store_objects(deployer) receiver_so = collect_store_objects(receiver) - # build all nodes without any keys + # build receiver with no keys deployer.succeed(f"wire apply --no-progress --on receiver --path {TEST_DIR}/hive.nix --no-keys --ssh-accept-host -vvv >&2") receiver.wait_for_unit("sshd.service") # --no-keys should never push a key - receiver.fail("test -f /run/keys/source_string") - deployer.fail("test -f /run/keys/source_string") + receiver.fail("test -f /run/keys/source_string_name") + deployer.fail("test -f /run/keys/source_string_name") + + # key services are created + receiver.succeed("systemctl cat source_string_name-key.service") + + _, is_failed = receiver.execute("systemctl is-failed source_string_name-key.service") + assert is_failed == "inactive\n", f"source_string_name-key.service must be inactive before key exists ({is_failed})" def test_keys(target, target_object, non_interactive): if non_interactive: @@ -30,13 +36,13 @@ deployer.succeed(f"wire apply keys --on {target} --no-progress --path {TEST_DIR}/hive.nix --ssh-accept-host -vvv >&2") keys = [ - ("/run/keys/source_string", "hello_world_source", "root root 600"), - ("/etc/keys/file", "hello_world_file", "root root 644"), - ("/home/owner/some/deep/path/command", "hello_world_command", "owner owner 644"), - ("/run/keys/environment", "string_from_environment", "root root 600"), + ("/run/keys/source_string_name", "hello_world_source", "root root 600", "source_string_name"), + ("/etc/keys/file", "hello_world_file", "root root 644", "file"), + ("/home/owner/some/deep/path/command", "hello_world_command", "owner owner 644", "command"), + ("/run/keys/environment", "string_from_environment", "root root 600", "environment"), ] - for path, value, permissions in keys: + for path, value, permissions, name in keys: # test existence & value source_string = target_object.succeed(f"cat {path}") assert value in source_string, f"{path} has correct contents ({target})" @@ -47,12 +53,23 @@ def perform_routine(target, target_object, non_interactive): test_keys(target, target_object, non_interactive) + # only check systemd units on receiver since deployer applys are one time only + if target == "receiver": + target_object.succeed("systemctl start source_string_name-key.path") + target_object.succeed("systemctl start command-key.path") + target_object.wait_for_unit("source_string_name-key.service") + target_object.wait_for_unit("command-key.service") + # Mess with the keys to make sure that every push refreshes the permissions target_object.succeed("echo 'incorrect_value' > /run/keys/source_string") target_object.succeed("chown 600 /etc/keys/file") # Test having a key that doesn't exist mixed with keys that do target_object.succeed("rm /home/owner/some/deep/path/command") + if target == "receiver": + _, is_failed = target_object.execute("systemctl is-active command-key.service") + assert is_failed == "failed\n", f"command-key.service is failed after deletion ({is_failed})" + # Test keys twice to ensure the operation is idempotent, # especially around directory creation. test_keys(target, target_object, non_interactive) diff --git a/tests/nix/suite/test_keys/hive.nix b/tests/nix/suite/test_keys/hive.nix index 3adcbf4..984e75c 100644 --- a/tests/nix/suite/test_keys/hive.nix +++ b/tests/nix/suite/test_keys/hive.nix @@ -9,6 +9,8 @@ makeHive { defaults = { deployment.keys = { source_string = { + # key with different name to attr name + name = "source_string_name"; source = '' hello_world_source ''; diff --git a/wire/lib/src/test_support.rs b/wire/lib/src/test_support.rs index 20e926a..236b348 100644 --- a/wire/lib/src/test_support.rs +++ b/wire/lib/src/test_support.rs @@ -2,7 +2,8 @@ // Copyright 2024-2025 wire Contributors use std::{ - fs, io, + fs::{self, create_dir}, + io, path::Path, process::Command, sync::{Arc, Mutex}, @@ -26,13 +27,23 @@ pub fn make_flake_sandbox(path: &Path) -> Result { let root = path.parent().unwrap().parent().unwrap().parent().unwrap(); + create_dir(tmp_dir.as_ref().join("module/"))?; + fs::copy( root.join(Path::new("runtime/evaluate.nix")), tmp_dir.as_ref().join("evaluate.nix"), )?; fs::copy( - root.join(Path::new("runtime/module.nix")), - tmp_dir.as_ref().join("module.nix"), + root.join(Path::new("runtime/module/config.nix")), + tmp_dir.as_ref().join("module/config.nix"), + )?; + fs::copy( + root.join(Path::new("runtime/module/options.nix")), + tmp_dir.as_ref().join("module/options.nix"), + )?; + fs::copy( + root.join(Path::new("runtime/module/default.nix")), + tmp_dir.as_ref().join("module/default.nix"), )?; fs::copy( root.join(Path::new("runtime/makeHive.nix")),