diff --git a/README.md b/README.md index c353203a..14d085b9 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,11 @@ This is a set of options that can be put in any of the above definitions, with t # This defaults to `false` interactiveSudo = false; + # Whether to enable the sops integration for password based sudo on the remote host. Useful when using non-root sshUsers. + # This defaults to not beeing used. + sudoFile = ./path.yaml; + sudoSecret = "secret"; + # This is an optional list of arguments that will be passed to SSH. sshOpts = [ "-p" "2121" ]; @@ -238,6 +243,28 @@ This is a set of options that can be put in any of the above definitions, with t Some of these options can be provided during `deploy` invocation to override default values or values provided in your flake, see `deploy --help`. +### Sudo on remote host + +There are two different ways to supply a password for elevating privileges on the remote host, but only one can be used at a time. +The first is `interactiveSudo`, where the user will get prompted for a password while running the deployment. +The other option is to use sops to provide the secrets. + +#### Sops + +In order to use the [sops](https://github.com/getsops/sops) integration `sudoFile` as well as `sudoSecret` have to be specified for a node. +While running the deployment `sops` is used to decrypt the path `sudoFile` and search for `sudoSecret` within the file. +When specifying the `sudoSecret` you can address the key as specified below: + +```yaml +password: + test: 123 +password_test_user: abc +``` + +You can refer to the password `123` as `password/test` and `abc` as `password_test_user`. +Keep in mind that we only handle nested secrets with strings, numbers and boolean. +For an example please see the [sops example](./examples/sops). + ## About Serokell deploy-rs is maintained and funded with ❤️ by [Serokell](https://serokell.io/). diff --git a/examples/sops/.sops.yaml b/examples/sops/.sops.yaml new file mode 100644 index 00000000..6f61afb2 --- /dev/null +++ b/examples/sops/.sops.yaml @@ -0,0 +1,7 @@ +keys: + - &primary age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm +creation_rules: + - path_regex: passwords.yaml + key_groups: + - age: + - *primary diff --git a/examples/sops/README.md b/examples/sops/README.md new file mode 100644 index 00000000..4aee1650 --- /dev/null +++ b/examples/sops/README.md @@ -0,0 +1,18 @@ +# Example NixOS system deployment where password is passed via sops + +This is an example of how to use the sops integration for deploy-rs. + +To decrypt the password manually use `SOPS_AGE_KEY_FILE=$(pwd)/age_private.txt sops -d passwords.yaml`. +Note that sops will try to search for the private key for age in `$XDG_CONFIG_HOME/sops/age/keys.txt` by default, +but this can be overridden with `SOPS_AGE_KEY_FILE`. For more information please see the [sops documentation](https://getsops.io/docs/#encrypting-using-age). + +1. Run bare system from `.#nixosConfigurations.sops` + +- `nix build .#nixosConfigurations.sops.config.system.build.vm` +- `QEMU_NET_OPTS=hostfwd=tcp::2221-:22 ./result/bin/run-sops-vm` +- you can manually ssh into the machine via `ssh deploy@localhost -p 2221 -i ./hello_ed25519` + +2. Develop the devshell via `nix develop .` to get sops, age and `deploy` added to $PATH +3. Run via `deploy .` to deploy the "new" Configuration updated +4. ??? +5. PROFIT!!! diff --git a/examples/sops/age_private.txt b/examples/sops/age_private.txt new file mode 100644 index 00000000..bb7aed4c --- /dev/null +++ b/examples/sops/age_private.txt @@ -0,0 +1,3 @@ +# created: 2025-06-05T11:36:08+02:00 +# public key: age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm +AGE-SECRET-KEY-1L8HTRL2THGGZLXQQDTDLDG0U8EL4RSSAMVT9RYUG5JWPUJW49N9QS0EFSZ diff --git a/examples/sops/age_public.txt b/examples/sops/age_public.txt new file mode 100644 index 00000000..c6ecd21a --- /dev/null +++ b/examples/sops/age_public.txt @@ -0,0 +1 @@ +age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm diff --git a/examples/sops/configuration.nix b/examples/sops/configuration.nix new file mode 100644 index 00000000..4ae91d86 --- /dev/null +++ b/examples/sops/configuration.nix @@ -0,0 +1,44 @@ +{ pkgs, ... }: +{ + networking.hostName = "sops"; + nix.settings = { + # allow the `deploy` user to push unsigned NARs + allowed-users = [ "deploy" ]; + trusted-users = [ "deploy" ]; + }; + + # setup a user for the deployment + users.users.deploy = { + isNormalUser = true; + password = "heloWorld"; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFnXmG3pSC8+UfmrHH0L5UtT++KqTmLp+1B3oWIJ1IBB hello@localhost" + ]; + extraGroups = [ + "wheel" + "sudo" + ]; # for sudo su + uid = 1010; + }; + + # setup the rest of the system + boot.loader = { + systemd-boot.enable = true; + efi.canTouchEfiVariables = true; + }; + + services.openssh.enable = true; + + nix.settings = { + substituters = pkgs.lib.mkForce [ ]; + experimental-features = "nix-command flakes"; + trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ]; + }; + + # settings for the vm + virtualisation = { + useBootLoader = true; + writableStore = true; + useEFIBoot = true; + }; +} diff --git a/examples/sops/flake.lock b/examples/sops/flake.lock new file mode 100644 index 00000000..fd6cb81c --- /dev/null +++ b/examples/sops/flake.lock @@ -0,0 +1,114 @@ +{ + "nodes": { + "deploy-rs": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": "nixpkgs", + "utils": "utils" + }, + "locked": { + "lastModified": 1749124551, + "narHash": "sha256-nVYAwE2vWtLExuAU/cVNZYASwJtbB9YolfFtFRicJAY=", + "owner": "weriomat", + "repo": "deploy-rs", + "rev": "e22ebb183aebe21d7edf371ab7aeceaae77c0cdf", + "type": "github" + }, + "original": { + "owner": "weriomat", + "ref": "sops", + "repo": "deploy-rs", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1743014863, + "narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1748929857, + "narHash": "sha256-lcZQ8RhsmhsK8u7LIFsJhsLh/pzR9yZ8yqpTzyGdj+Q=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "c2a03962b8e24e669fb37b7df10e7c79531ff1a4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "deploy-rs": "deploy-rs", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/examples/sops/flake.nix b/examples/sops/flake.nix new file mode 100644 index 00000000..98d2eb3c --- /dev/null +++ b/examples/sops/flake.nix @@ -0,0 +1,76 @@ +{ + description = "Deploy a full system where the password is supplied via sops"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + deploy-rs.url = "github:weriomat/deploy-rs/sops"; + }; + + outputs = + { + self, + nixpkgs, + deploy-rs, + ... + }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + in + { + nixosConfigurations = { + sops = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + ./configuration.nix + (pkgs.path + "/nixos/modules/virtualisation/qemu-vm.nix") + ]; + }; + updated = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + (pkgs.path + "/nixos/modules/virtualisation/qemu-vm.nix") + ./configuration.nix + ./updated.nix + ]; + }; + }; + + # packages we need to inspect the encrypted files + devShells.x86_64-linux.default = pkgs.mkShell { + buildInputs = [ + deploy-rs.packages.default + pkgs.sops + pkgs.age + ]; + }; + + deploy.nodes.example = { + sshOpts = [ + "-p" + "2221" + ]; + hostname = "localhost"; + fastConnection = true; + sudoFile = ./passwords.yaml; + + profiles.system = { + user = "root"; + sshUser = "deploy"; + + # sudo password is gotten via + sudoSecret = "password/deploy"; + + # we setup ssh auth with this key, these will get merged with the settings above + sshOpts = [ + "-i" + "./hello_ed25519" + ]; + + path = deploy-rs.lib.x86_64-linux.activate.nixos self.nixosConfigurations.updated; # this is a bit hacky to get a "updated" configuration to deploy + }; + }; + + checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib; + }; +} diff --git a/examples/sops/hello_ed25519 b/examples/sops/hello_ed25519 new file mode 100644 index 00000000..26aad8b6 --- /dev/null +++ b/examples/sops/hello_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBZ15ht6UgvPlH5qxx9C+VLU/viqk5i6ftQd6FiCdSAQQAAAJhLKSuiSykr +ogAAAAtzc2gtZWQyNTUxOQAAACBZ15ht6UgvPlH5qxx9C+VLU/viqk5i6ftQd6FiCdSAQQ +AAAECHxBqQ8m4mlSF5N83v6x2XxUZB1ao85TyroGq333v5v1nXmG3pSC8+UfmrHH0L5UtT +++KqTmLp+1B3oWIJ1IBBAAAAD2hlbGxvQGxvY2FsaG9zdAECAwQFBg== +-----END OPENSSH PRIVATE KEY----- diff --git a/examples/sops/hello_ed25519.pub b/examples/sops/hello_ed25519.pub new file mode 100644 index 00000000..0ba4a725 --- /dev/null +++ b/examples/sops/hello_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFnXmG3pSC8+UfmrHH0L5UtT++KqTmLp+1B3oWIJ1IBB hello@localhost diff --git a/examples/sops/passwords.yaml b/examples/sops/passwords.yaml new file mode 100644 index 00000000..803ab210 --- /dev/null +++ b/examples/sops/passwords.yaml @@ -0,0 +1,17 @@ +password: + deploy: ENC[AES256_GCM,data:t8IlqzJr5v+A,iv:L3/+IQ6+gl/az3ya+rc/yFJ89vdjI6NvletIyhO5EcA=,tag:sCzIp1WvRlpLtlm/i1AXmw==,type:str] +sops: + age: + - recipient: age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTUjRhQ2pieU1XakR5eTFH + elpocXhFYUVBL3p4Z0RjcDhvcEZmNEtVWkNvCngvT21NNFpSNENwVVRsemROcGRN + aGJEVWhmWmI2dFQrRWw1RUhudElNMU0KLS0tIFYwUmQzLzVqVzdxSkdZTXIrMGVG + ZGZ4eS82bjZvWmxRRk1wbHFsZ2RlUGcKQeaupX894rGIal5ov0MOSaRVd4OQ7muQ + IkCtwZ+v2nn4xtd9MEdNur8z81civvCz907fmlKxtyk9NSLY8UP54w== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2025-06-05T15:16:06Z" + mac: ENC[AES256_GCM,data:k7mANamp8envEFkAcsbbpvw3GoffaBBI4N7S90QFIX5bxdPsMQvQ9Lddh6nJAjYX0DGBUafVn3c2C1LXTJAmHbrLoqgn5uW18AHhNhnIt9M+5zXcT9olxoMt/aDd6OucCJ0N/vQ5fuD5HfFJwoANIg+2cEONfNoubspiI+K+Y/4=,iv:9GnnM7amxpmFGVpJ4q0pwJDFuldt5bAISrTxdJS9FbU=,tag:0AGdq2+jxQpjRv7zNIXFBg==,type:str] + unencrypted_suffix: _unencrypted + version: 3.10.2 diff --git a/examples/sops/updated.nix b/examples/sops/updated.nix new file mode 100644 index 00000000..d2f8540a --- /dev/null +++ b/examples/sops/updated.nix @@ -0,0 +1,17 @@ +{ lib, ... }: +let + inherit (lib) mkForce; +in +{ + # update some config + networking.hostName = mkForce "updated"; + users = { + users.updated = { + isNormalUser = true; + password = "aReallyComplicatedPassword"; + uid = 1011; + group = "updated"; + }; + groups.updated = { }; + }; +} diff --git a/flake.nix b/flake.nix index 78e111a9..99a6c9ac 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,6 @@ in { deploy-rs = { - deploy-rs = final.rustPlatform.buildRustPackage (darwinOptions // { pname = "deploy-rs"; version = "0.1.0"; @@ -40,6 +39,8 @@ ".*\.rs$" ]; + runtimeInputs = [ final.pkgs.sops ]; + cargoLock.lockFile = ./Cargo.lock; meta = { description = "A Simple multi-profile Nix-flake deploy tool"; @@ -164,13 +165,13 @@ }; in { - packages.default = self.packages."${system}".deploy-rs; + packages.default = self.packages.${system}.deploy-rs; packages.deploy-rs = pkgs.deploy-rs.deploy-rs; - apps.default = self.apps."${system}".deploy-rs; + apps.default = self.apps.${system}.deploy-rs; apps.deploy-rs = { type = "app"; - program = "${self.packages."${system}".default}/bin/deploy"; + program = "${self.packages.${system}.default}/bin/deploy"; }; devShells.default = pkgs.mkShell { @@ -184,6 +185,7 @@ rustfmt clippy reuse + sops rust.packages.stable.rustPlatform.rustLibSrc ]; }; diff --git a/interface.json b/interface.json index a96d1c2d..e7813cc5 100644 --- a/interface.json +++ b/interface.json @@ -38,6 +38,12 @@ }, "interactiveSudo": { "type": "boolean" + }, + "sudoFile": { + "type": "path" + }, + "sudoSecret": { + "type": "string" } } }, diff --git a/nix/tests/default.nix b/nix/tests/default.nix index 3ee755a2..d559ac90 100644 --- a/nix/tests/default.nix +++ b/nix/tests/default.nix @@ -20,7 +20,7 @@ let done <$refs ''; - mkTest = { name ? "", user ? "root", flakes ? true, isLocal ? true, deployArgs }: let + mkTest = { name ? "", user ? "root", flakes ? true, isLocal ? true, sops ? false, deployArgs }: let nodes = { server = { nodes, ... }: { imports = [ @@ -36,7 +36,7 @@ let }; client = { nodes, ... }: { imports = [ (import ./common.nix { inherit inputs pkgs flakes; }) ]; - environment.systemPackages = [ pkgs.deploy-rs.deploy-rs ]; + environment.systemPackages = [ pkgs.deploy-rs.deploy-rs ] ++ lib.optionals sops [ pkgs.sops ]; # nix evaluation takes a lot of memory, especially in non-flake usage virtualisation.memorySize = lib.mkForce 4096; virtualisation.additionalPaths = lib.optionals isLocal [ @@ -97,6 +97,14 @@ let client.succeed("cp ${./server.nix} ./server.nix") client.succeed("cp ${./common.nix} ./common.nix") client.succeed("cp ${serverNetworkJSON} ./network.json") + + # Prepare sops keys + client.succeed("cp ${./sops/.sops.yaml} ./.sops.yaml") + client.succeed("cp ${./sops/password.yaml} ./password.yaml") + # this is where sops looks for private keys + client.succeed("mkdir -p /root/.config/sops/age/") + client.succeed("cp ${./sops/age_private.txt} /root/.config/sops/age/keys.txt") + client.succeed("nix --extra-experimental-features flakes flake lock") # Setup SSH key @@ -115,6 +123,9 @@ let # Make sure the hello and figlet packages are missing server.fail("su ${user} -l -c 'hello | figlet'") + # Create a missing directory + server.succeed("mkdir -p /root/.local/state/nix/profiles") + # Deploy to the server client.succeed("deploy ${deployArgs}") @@ -158,7 +169,7 @@ in { deployArgs = "-s .#profile -- --offline"; }; hyphen-ssh-opts-regression = mkTest { - name = "profile"; + name = "ssh-ops-regression"; user = "deploy"; deployArgs = "-s .#profile --ssh-opts '-p 22 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' -- --offline"; }; @@ -173,4 +184,16 @@ in { flakes = true; deployArgs = "--file . --targets server"; }; + sops = mkTest { + name = "sops"; + user = "sops"; + sops = true; + deployArgs = "-s .#sops"; + }; + sops-override-arguments = mkTest { + name = "sops-override-arguments"; + user = "sops"; + sops = true; + deployArgs = "-s .#server --sudo-file ./password.yaml --sudo-secret passwords/sops --ssh-user sops"; + }; } diff --git a/nix/tests/deploy-flake.nix b/nix/tests/deploy-flake.nix index 47f0f5b4..bfc3c3b3 100644 --- a/nix/tests/deploy-flake.nix +++ b/nix/tests/deploy-flake.nix @@ -36,7 +36,7 @@ "-o" "StrictHostKeyChecking=no" "-o" "StrictHostKeyChecking=no" ]; - profiles.system.path = deploy-rs.lib."${system}".activate.nixos self.nixosConfigurations.server; + profiles.system.path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.server; }; server-override = { hostname = "override"; @@ -46,7 +46,7 @@ sshOpts = [ ]; confirmTimeout = 0; activationTimeout = 0; - profiles.system.path = deploy-rs.lib."${system}".activate.nixos self.nixosConfigurations.server; + profiles.system.path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.server; }; profile = { hostname = "server"; @@ -67,6 +67,14 @@ in deploy-rs.lib.${system}.activate.custom activateProfile "$PROFILE/bin/activate"; }; }; + sops = { + hostname = "server"; + sshUser = "sops"; + sshOpts = [ "-o" "StrictHostKeyChecking=no" ]; + sudoFile = ./password.yaml; + sudoSecret = "passwords/sops"; + profiles.system.path = deploy-rs.lib.${system}.activate.nixos self.nixosConfigurations.server; + }; }; }; } diff --git a/nix/tests/server.nix b/nix/tests/server.nix index a8bbda62..ce0a4352 100644 --- a/nix/tests/server.nix +++ b/nix/tests/server.nix @@ -4,20 +4,49 @@ { pkgs, ... }: { nix.settings.trusted-users = [ "deploy" ]; - users = let - inherit (import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs) snakeOilPublicKey; - in { - mutableUsers = false; - users = { - deploy = { - password = ""; - isNormalUser = true; - createHome = true; - openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; + users = + let + inherit (import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs) snakeOilPublicKey; + in + { + mutableUsers = false; + users = { + deploy = { + password = ""; + isNormalUser = true; + createHome = true; + group = "deploy"; + extraGroups = [ "wheel" ]; # need wheel for `sudo su` + openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; + }; + sops = { + password = "rootIsAGoodRootPassword"; + isNormalUser = true; + createHome = true; + group = "sops"; + extraGroups = [ "wheel" ]; # need wheel for `sudo su` + openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; + }; + root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; + }; + groups = { + deploy = { }; + sops = { }; }; - root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; }; - }; + + # deploy does not need a password for sudo + security.sudo.extraRules = [ + { + groups = [ "deploy" ]; + commands = [ + { + command = "ALL"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; services.openssh.enable = true; virtualisation.writableStore = true; } diff --git a/nix/tests/sops/.sops.yaml b/nix/tests/sops/.sops.yaml new file mode 100644 index 00000000..3790f044 --- /dev/null +++ b/nix/tests/sops/.sops.yaml @@ -0,0 +1,7 @@ +keys: + - &primary age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm +creation_rules: + - path_regex: password.yaml + key_groups: + - age: + - *primary diff --git a/nix/tests/sops/age_private.txt b/nix/tests/sops/age_private.txt new file mode 100644 index 00000000..bb7aed4c --- /dev/null +++ b/nix/tests/sops/age_private.txt @@ -0,0 +1,3 @@ +# created: 2025-06-05T11:36:08+02:00 +# public key: age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm +AGE-SECRET-KEY-1L8HTRL2THGGZLXQQDTDLDG0U8EL4RSSAMVT9RYUG5JWPUJW49N9QS0EFSZ diff --git a/nix/tests/sops/age_public.txt b/nix/tests/sops/age_public.txt new file mode 100644 index 00000000..c6ecd21a --- /dev/null +++ b/nix/tests/sops/age_public.txt @@ -0,0 +1 @@ +age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm diff --git a/nix/tests/sops/password.yaml b/nix/tests/sops/password.yaml new file mode 100644 index 00000000..3a176816 --- /dev/null +++ b/nix/tests/sops/password.yaml @@ -0,0 +1,17 @@ +passwords: + sops: ENC[AES256_GCM,data:DXcCPJcsVWA4oja7DFCUERRLR98rcbY=,iv:NaqB7ogUGJUAg0wg4J1xsUEEX48lbDUhRNfuRlBp5YI=,tag:e+D9XwazhhHhqPefepUbrA==,type:str] +sops: + age: + - recipient: age179s8jnppgy9kwakmva8av6frpnhgg9myrvk3xlfpanmhvvzyh96sdygfcm + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMckNqSWF3MUpNOU91T2Zh + dU9ZQlozVWd3bnhsakZRSlFxcGVCZ3Z1dGpvCkVtbkhDNkNhTU5PUHhuVm5BaTJa + dGRhU0c5MmQ2bVdyc1JnVVB0aCt1YW8KLS0tIHd4NWlDZEQvdEhxb2lVaUtmSktO + MDExNzUwUG5KZ2JyZHhKTmFLZEpleWcKDNxV1CKbEeQ4ixX4PMSj60egj31bN2KG + Zm0wfO8UtuGkLVcPKLL7jUhgQXzN9jHg/fDzT11tTnmFaEwtfhHzWg== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2025-06-06T17:18:18Z" + mac: ENC[AES256_GCM,data:A6ee/YUtR65KK2697QLKzKgGvaGUZZjgv5Ie2fUBnxtnTf5aWx0E+2mY1krbwv02Wr9HAr/BJnSKqvFrANEA2x58Ijgin+g8nVgspxshTgG1TPXL1+cG724kxheiU0TRnmBwumyEsVlpSjxbyeEDngH4oB9rIuc33Sp7Z1PAXpA=,iv:fgRXSEDjYyjRxwTN1/ZIokgdzE8wjpUALzEUAdWhUIQ=,tag:4lR0+tp/EqXB4qqv4UhxkQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.10.2 diff --git a/src/cli.rs b/src/cli.rs index 43990f5c..22533054 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::io::{stdin, stdout, Write}; +use std::str::Utf8Error; use clap::{ArgMatches, Parser, FromArgMatches}; @@ -17,6 +18,7 @@ use serde::Serialize; use std::path::PathBuf; use std::process::Stdio; use thiserror::Error; +use tokio::fs::try_exists; use tokio::process::Command; /// Simple Rust rewrite of a simple Nix Flake deployment tool @@ -109,6 +111,12 @@ pub struct Opts { /// Prompt for sudo password during activation. #[arg(long)] interactive_sudo: Option, + /// File for the sudo password with sops integration + #[arg(long)] + sudo_file: Option, + /// Key for the sudo password with sops integration + #[arg(long)] + sudo_secret: Option, } /// Returns if the available Nix installation supports flakes @@ -404,7 +412,9 @@ pub enum RunDeployError { #[error("Failed to revoke profile for node {0}: {1}")] RevokeProfile(String, deploy::deploy::RevokeProfileError), #[error("Deployment to node {0} failed, rolled back to previous generation")] - Rollback(String) + Rollback(String), + #[error("Failed to get the password from sops: {0}")] + Sops(#[from] deploy::cli::SopsError), } type ToDeploy<'a> = Vec<( @@ -548,21 +558,103 @@ async fn run_deploy( let mut deploy_defs = deploy_data.defs()?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data.merged_settings.sudo.is_some() + && (deploy_data.merged_settings.interactive_sudo.is_some() + || deploy_data.merged_settings.sudo_secret.is_some()) + { + warn!("Custom sudo commands should be configured to accept password input from stdin when using the 'interactive sudo' or 'password File' option. Deployment may fail if the custom command ignores stdin."); + } else { + // this configures sudo to hide the password prompt and accept input from stdin + // at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root + let original = deploy_defs.sudo.unwrap_or("sudo".to_string()); + deploy_defs.sudo = Some(format!("{} -S -p \"\"", original)); + } + + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + { warn!("Interactive sudo is enabled! Using a sudo password is less secure than correctly configured SSH keys.\nPlease use keys in production environments."); - if deploy_data.merged_settings.sudo.is_some() { - warn!("Custom sudo commands should be configured to accept password input from stdin when using the 'interactive sudo' option. Deployment may fail if the custom command ignores stdin."); - } else { - // this configures sudo to hide the password prompt and accept input from stdin - // at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root - let original = deploy_defs.sudo.unwrap_or("sudo".to_string()); - deploy_defs.sudo = Some(format!("{} -S -p \"\"", original)); - } + info!( + "You will now be prompted for the sudo password for {}.", + node.node_settings.hostname + ); - info!("You will now be prompted for the sudo password for {}.", node.node_settings.hostname); - let sudo_password = rpassword::prompt_password(format!("(sudo for {}) Password: ", node.node_settings.hostname)).unwrap_or("".to_string()); + let sudo_password = rpassword::prompt_password(format!( + "(sudo for {}) Password: ", + node.node_settings.hostname + )) + .unwrap_or("".to_string()); + + deploy_defs.sudo_password = Some(sudo_password); + } else if deploy_data.merged_settings.sudo_file.is_some() + && deploy_data.merged_settings.sudo_secret.is_some() + { + // SAFETY: we already checked if it is some + let path = deploy_data.merged_settings.sudo_file.clone().unwrap(); + let key = deploy_data.merged_settings.sudo_secret.clone().unwrap(); + + if !try_exists(&path).await.unwrap() { + return Err(RunDeployError::Sops(SopsError::SopsFileNotFound(format!( + "{path:?} not found" + )))); + } + // We deserialze to json + let out = Command::new("sops") + .arg("--output-type") + .arg("json") + .arg("-d") + .arg(&path) + .output() + .await + .map_err(|err| { + RunDeployError::Sops(SopsError::SopsFailedDecryption( + path.to_string_lossy().into(), + err, + )) + })?; + + let conv_out = std::str::from_utf8(&out.stdout) + .map_err(|err| RunDeployError::Sops(SopsError::SopsCannotConvert(err)))?; + + let mut m: serde_json::Map = serde_json::from_str(conv_out) + .map_err(|err| RunDeployError::Sops(SopsError::SerdeDeserialize(err)))?; + + let mut sudo_password = String::new(); + + // We support nested keys like a/b/c + for i in key.split('/') { + match m.get(i) { + Some(v) => match v { + serde_json::Value::String(s) => { + sudo_password = s.into(); + } + serde_json::Value::Bool(b) => { + sudo_password = b.to_string(); + } + serde_json::Value::Number(n) => { + sudo_password = n.to_string(); + } + serde_json::Value::Object(map) => { + m = map.clone(); + } + _ => { + return Err(RunDeployError::Sops(SopsError::SerdeUnexpectedType( + "We dont handle Arrays, Bools, None, Numbers".into(), + ))); + } + }, + None => { + return Err(RunDeployError::Sops(SopsError::SopsKeyNotFound(format!( + "Did not find {} in Map", + i + )))); + } + } + } deploy_defs.sudo_password = Some(sudo_password); } @@ -639,6 +731,22 @@ async fn run_deploy( Ok(()) } +#[derive(Error, Debug)] +pub enum SopsError { + #[error("Failed to decrypt file {0}: {1}")] + SopsFailedDecryption(String, std::io::Error), + #[error("Failed to find sops file: {0}")] + SopsFileNotFound(String), + #[error("Failed to convert the output of sops to a str: {0}")] + SopsCannotConvert(Utf8Error), + #[error("Failed to deserialize: {0}")] + SerdeDeserialize(serde_json::Error), + #[error("Error unexpected type: {0}")] + SerdeUnexpectedType(String), + #[error("Failed to find key: {0}")] + SopsKeyNotFound(String), +} + #[derive(Error, Debug)] pub enum RunError { #[error("Failed to deploy profile: {0}")] @@ -710,7 +818,9 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> { dry_activate: opts.dry_activate, remote_build: opts.remote_build, sudo: opts.sudo, - interactive_sudo: opts.interactive_sudo + interactive_sudo: opts.interactive_sudo, + sudo_file: opts.sudo_file, + sudo_secret: opts.sudo_secret, }; let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?; diff --git a/src/data.rs b/src/data.rs index 12b0f01b..a2467f6e 100644 --- a/src/data.rs +++ b/src/data.rs @@ -37,6 +37,11 @@ pub struct GenericSettings { pub remote_build: Option, #[serde(rename(deserialize = "interactiveSudo"))] pub interactive_sudo: Option, + // sops integration for secrets + #[serde(rename(deserialize = "sudoFile"))] + pub sudo_file: Option, + #[serde(rename(deserialize = "sudoSecret"))] + pub sudo_secret: Option, } #[derive(Deserialize, Debug, Clone)] diff --git a/src/deploy.rs b/src/deploy.rs index fd535443..c8f74a78 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -301,7 +301,12 @@ pub async fn confirm_profile( .spawn() .map_err(ConfirmProfileError::SSHConfirm)?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + || deploy_data.merged_settings.sudo_secret.is_some() + { trace!("[confirm] Piping in sudo password"); handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs) .await @@ -413,7 +418,12 @@ pub async fn deploy_profile( .spawn() .map_err(DeployProfileError::SSHSpawnActivate)?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + || deploy_data.merged_settings.sudo_secret.is_some() + { trace!("[activate] Piping in sudo password"); handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) .await @@ -454,7 +464,12 @@ pub async fn deploy_profile( .spawn() .map_err(DeployProfileError::SSHSpawnActivate)?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + || deploy_data.merged_settings.sudo_secret.is_some() + { trace!("[activate] Piping in sudo password"); handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) .await @@ -498,7 +513,12 @@ pub async fn deploy_profile( .spawn() .map_err(DeployProfileError::SSHWait)?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + || deploy_data.merged_settings.sudo_secret.is_some() + { trace!("[wait] Piping in sudo password"); handle_sudo_stdin(&mut ssh_wait_child, deploy_defs) .await @@ -581,7 +601,12 @@ pub async fn revoke( .spawn() .map_err(RevokeProfileError::SSHSpawnRevoke)?; - if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { + if deploy_data + .merged_settings + .interactive_sudo + .unwrap_or(false) + || deploy_data.merged_settings.sudo_secret.is_some() + { trace!("[revoke] Piping in sudo password"); handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs) .await diff --git a/src/lib.rs b/src/lib.rs index 91ab7c76..71dcbec1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -166,6 +166,8 @@ pub struct CmdOverrides { pub activation_timeout: Option, pub sudo: Option, pub interactive_sudo: Option, + pub sudo_file: Option, + pub sudo_secret: Option, pub dry_activate: bool, pub remote_build: bool, } @@ -365,6 +367,12 @@ enum ProfileInfo { pub enum DeployDataDefsError { #[error("Neither `user` nor `sshUser` are set for profile {0} of node {1}")] NoProfileUser(String, String), + #[error("No sudo path set but sudo secret for profile {0} of node {1}")] + NoSopsFile(String, String), + #[error("No sudo secret but sudo path set for profile {0} of node {1}")] + NoSopsSecret(String, String), + #[error("Interactive Sudo set but sudo secret set as well for profile {0} of node {1}")] + SopsButInteractive(String, String), } impl<'a> DeployData<'a> { @@ -381,6 +389,31 @@ impl<'a> DeployData<'a> { _ => None, }; + // Check if one of sudo_file or sudo_secret is missing + if self.merged_settings.sudo_file.is_some() && self.merged_settings.sudo_secret.is_none() { + return Err(DeployDataDefsError::NoSopsSecret( + self.profile_name.to_owned(), + self.node_name.to_owned(), + )); + } + + if self.merged_settings.sudo_file.is_none() && self.merged_settings.sudo_secret.is_some() { + return Err(DeployDataDefsError::NoSopsFile( + self.profile_name.to_owned(), + self.node_name.to_owned(), + )); + } + + // Check that only either sudo_secret or interactive sudo is set + if self.merged_settings.interactive_sudo.is_some() + && self.merged_settings.sudo_secret.is_some() + { + return Err(DeployDataDefsError::SopsButInteractive( + self.profile_name.to_owned(), + self.node_name.to_owned(), + )); + } + Ok(DeployDefs { ssh_user, profile_user, @@ -468,6 +501,12 @@ pub fn make_deploy_data<'a, 's>( if let Some(interactive_sudo) = cmd_overrides.interactive_sudo { merged_settings.interactive_sudo = Some(interactive_sudo); } + if let Some(ref sudo_file) = cmd_overrides.sudo_file { + merged_settings.sudo_file = Some(sudo_file.to_owned()); + } + if let Some(ref sudo_secret) = cmd_overrides.sudo_secret { + merged_settings.sudo_secret = Some(sudo_secret.to_owned()); + } DeployData { node_name,