1
0
Fork 0
mirror of https://github.com/Mic92/sops-nix.git synced 2024-12-14 11:57:52 +00:00

Implement darwin module for sops-nix

This commit is contained in:
Ian 2024-11-03 19:51:58 +00:00 committed by mergify[bot]
parent 4c91d52db1
commit d2bd7f433b
7 changed files with 514 additions and 6 deletions

View file

@ -32,6 +32,10 @@
};
homeManagerModules.sops = ./modules/home-manager/sops.nix;
homeManagerModule = self.homeManagerModules.sops;
darwinModules = {
sops = ./modules/nix-darwin;
default = self.darwinModules.sops;
};
packages = forAllSystems (system:
import ./default.nix {
pkgs = import nixpkgs {inherit system;};

View file

@ -0,0 +1,339 @@
{ config, options, lib, pkgs, ... }:
let
cfg = config.sops;
sops-install-secrets = cfg.package;
manifestFor = pkgs.callPackage ./manifest-for.nix {
inherit cfg;
inherit (pkgs) writeTextFile;
};
manifest = manifestFor "" regularSecrets {};
pathNotInStore = lib.mkOptionType {
name = "pathNotInStore";
description = "path not in the Nix store";
descriptionClass = "noun";
check = x: !lib.path.hasStorePathPrefix (/. + x);
merge = lib.mergeEqualOption;
};
regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets;
withEnvironment = import ./with-environment.nix {
inherit cfg lib;
};
secretType = lib.types.submodule ({ config, ... }: {
config = {
sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
sopsFileHash = lib.mkOptionDefault (lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}");
};
options = {
name = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets
'';
};
key = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
'';
};
path = lib.mkOption {
type = lib.types.str;
default = if config.neededForUsers then "/run/secrets-for-users/${config.name}" else "/run/secrets/${config.name}";
defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise.";
description = ''
Path where secrets are symlinked to.
If the default is kept no symlink is created.
'';
};
format = lib.mkOption {
type = lib.types.enum ["yaml" "json" "binary" "dotenv" "ini"];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
owner = lib.mkOption {
type = with lib.types; nullOr str;
default = "root";
description = ''
User of the file. Can only be set if uid is 0.
'';
};
uid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist.
'';
};
group = lib.mkOption {
type = with lib.types; nullOr str;
default = "staff";
defaultText = "staff";
description = ''
Group of the file. Can only be set if gid is 0.
'';
};
gid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist.
'';
};
sopsFile = lib.mkOption {
type = lib.types.path;
defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
sopsFileHash = lib.mkOption {
type = lib.types.str;
readOnly = true;
description = ''
Hash of the sops file.
'';
};
neededForUsers = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
**Warning** This option doesn't have any effect on macOS, as nix-darwin cannot manage user passwords on macOS.
This can be used to retrieve user's passwords from sops-nix.
Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root.
'';
};
};
});
darwinSSHKeys = [{
type = "rsa";
path = "/etc/ssh/ssh_host_rsa_key";
} {
type = "ed25519";
path = "/etc/ssh/ssh_host_ed25519_key";
}];
escapedKeyFile = lib.escapeShellArg cfg.age.keyFile;
# Skip ssh keys deployed with sops to avoid a catch 22
defaultImportKeys = algo:
map (e: e.path) (lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) darwinSSHKeys);
installScript = ''
${if cfg.age.generateKey then ''
if [[ ! -f ${escapedKeyFile} ]]; then
echo generating machine-specific age key...
mkdir -p $(dirname ${escapedKeyFile})
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedKeyFile}
fi
'' else ""}
echo "Setting up secrets..."
${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"}
'';
in {
options.sops = {
secrets = lib.mkOption {
type = lib.types.attrsOf secretType;
default = {};
description = ''
Path where the latest secrets are mounted to.
'';
};
defaultSopsFile = lib.mkOption {
type = lib.types.path;
description = ''
Default sops file used for all secrets.
'';
};
defaultSopsFormat = lib.mkOption {
type = lib.types.str;
default = "yaml";
description = ''
Default sops format used for all secrets.
'';
};
validateSopsFiles = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Check all sops files at evaluation time.
This requires sops files to be added to the nix store.
'';
};
keepGenerations = lib.mkOption {
type = lib.types.ints.unsigned;
default = 1;
description = ''
Number of secrets generations to keep. Setting this to 0 disables pruning.
'';
};
log = lib.mkOption {
type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]);
default = [ "keyImport" "secretChanges" ];
description = "What to log";
};
environment = lib.mkOption {
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path);
default = {};
description = ''
Environment variables to set before calling sops-install-secrets.
The values are placed in single quotes and not escaped any further to
allow usage of command substitutions for more flexibility. To properly quote
strings with quotes use lib.escapeShellArg.
This will be evaluated twice when using secrets that use neededForUsers but
in a subshell each time so the environment variables don't collide.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = (pkgs.callPackage ../.. {}).sops-install-secrets;
defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets";
description = ''
sops-install-secrets package to use.
'';
};
validationPackage = lib.mkOption {
type = lib.types.package;
default =
if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform
then sops-install-secrets
else (pkgs.pkgsBuildHost.callPackage ../.. {}).sops-install-secrets;
defaultText = lib.literalExpression "config.sops.package";
description = ''
sops-install-secrets package to use when validating configuration.
Defaults to sops.package if building natively, and a native version of sops-install-secrets if cross compiling.
'';
};
age = {
keyFile = lib.mkOption {
type = lib.types.nullOr pathNotInStore;
default = null;
example = "/var/lib/sops-nix/key.txt";
description = ''
Path to age key file used for sops decryption.
'';
};
generateKey = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether or not to generate the age key. If this
option is set to false, the key must already be
present at the specified location.
'';
};
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = defaultImportKeys "ed25519";
defaultText = lib.literalMD "The ed25519 keys from {option}`config.services.openssh.hostKeys`";
description = ''
Paths to ssh keys added as age keys during sops description.
'';
};
};
gnupg = {
home = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/root/.gnupg";
description = ''
Path to gnupg database directory containing the key for decrypting the sops file.
'';
};
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = defaultImportKeys "rsa";
defaultText = lib.literalMD "The rsa keys from {option}`config.services.openssh.hostKeys`";
description = ''
Path to ssh keys added as GPG keys during sops description.
This option must be explicitly unset if <literal>config.sops.gnupg.home</literal> is set.
'';
};
};
};
imports = [
./templates
./secrets-for-users
];
config = lib.mkMerge [
(lib.mkIf (cfg.secrets != {}) {
assertions = [{
assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != [];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
} {
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}] ++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (lib.mapAttrsToList (name: secret: [{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
} {
assertion =
builtins.isPath secret.sopsFile ||
(builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
} {
assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null;
message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set";
} {
assertion = secret.gid != null && secret.gid != 0 -> secret.group == null;
message = "In ${secret.name} exactly one of sops.group and sops.gid must be set";
}]) cfg.secrets)
);
system.build.sops-nix-manifest = manifest;
system.activationScripts = {
postActivation.text = lib.mkAfter installScript;
};
launchd.daemons.sops-install-secrets = {
command = installScript;
serviceConfig = {
RunAtLoad = true;
KeepAlive = false;
};
};
})
{
sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != []) (lib.mkDefault "${pkgs.gnupg}/bin/gpg");
}
];
}

View file

@ -0,0 +1,29 @@
{ writeTextFile, cfg }:
suffix: secrets: extraJson:
writeTextFile {
name = "manifest${suffix}.json";
text = builtins.toJSON ({
secrets = builtins.attrValues secrets;
# Does this need to be configurable?
secretsMountPoint = "/run/secrets.d";
symlinkPath = "/run/secrets";
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
useTmpfs = false;
templates = cfg.templates;
placeholderBySecretName = cfg.placeholder;
userMode = false;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
};
} // extraJson);
checkPhase = ''
${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out"
'';
}

View file

@ -0,0 +1,42 @@
{ lib, options, config, pkgs, ... }:
let
cfg = config.sops;
secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets;
manifestFor = pkgs.callPackage ../manifest-for.nix {
inherit cfg;
inherit (pkgs) writeTextFile;
};
withEnvironment = import ../with-environment.nix {
inherit cfg lib;
};
manifestForUsers = manifestFor "-for-users" secretsForUsers {
secretsMountPoint = "/run/secrets-for-users.d";
symlinkPath = "/run/secrets-for-users";
};
installScript = ''
echo "Setting up secrets for users"
${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"}
'';
in
{
assertions = [{
assertion = (lib.filterAttrs (_: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root")) secretsForUsers) == { };
message = "neededForUsers cannot be used for secrets that are not root-owned";
}];
system.activationScripts = lib.mkIf (secretsForUsers != []) {
postActivation.text = lib.mkAfter installScript;
};
launchd.daemons.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != []) {
command = installScript;
serviceConfig = {
RunAtLoad = true;
KeepAlive = false;
};
};
system.build.sops-nix-users-manifest = manifestForUsers;
}

View file

@ -0,0 +1,87 @@
{ config, pkgs, lib, options, ... }:
let
inherit (lib)
mkOption
mkDefault
mapAttrs
types
;
in {
options.sops = {
templates = mkOption {
description = "Templates for secret files";
type = types.attrsOf (types.submodule ({ config, ... }: {
options = {
name = mkOption {
type = types.singleLineStr;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets/rendered
'';
};
path = mkOption {
description = "Path where the rendered file will be placed";
type = types.singleLineStr;
default = "/run/secrets/rendered/${config.name}";
};
content = mkOption {
type = types.lines;
default = "";
description = ''
Content of the file
'';
};
mode = mkOption {
type = types.singleLineStr;
default = "0400";
description = ''
Permissions mode of the rendered secret file in octal.
'';
};
owner = mkOption {
type = types.singleLineStr;
default = "root";
description = ''
User of the file.
'';
};
group = mkOption {
type = types.singleLineStr;
default = "staff";
defaultText = "staff";
description = ''
Group of the file. Default on darwin in staff.
'';
};
file = mkOption {
type = types.path;
default = pkgs.writeText config.name config.content;
defaultText = lib.literalExpression ''pkgs.writeText config.name config.content'';
example = "./configuration-template.conf";
description = ''
File used as the template. When this value is specified, `sops.templates.<name>.content` is ignored.
'';
};
};
}));
default = { };
};
placeholder = mkOption {
type = types.attrsOf (types.mkOptionType {
name = "coercibleToString";
description = "value that can be coerced to string";
check = lib.strings.isConvertibleWithToString;
merge = lib.mergeEqualOption;
});
default = { };
visible = false;
};
};
config = lib.optionalAttrs (options ? sops.secrets)
(lib.mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs
(name: _: mkDefault "<SOPS:${builtins.hashString "sha256" name}:PLACEHOLDER>")
config.sops.secrets;
});
}

View file

@ -0,0 +1,13 @@
{ cfg, lib }:
sopsCall:
if cfg.environment == {} then
sopsCall
else ''
(
# shellcheck disable=SC2030,SC2031
${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)}
${sopsCall}
)
''

View file

@ -6,7 +6,6 @@ package main
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strings"
@ -71,21 +70,16 @@ func MountSecretFs(mountpoint string, keysGID int, _useTmpfs bool, userMode bool
size := mb * 1024 * 1024 / 512 // size in sectors a 512 bytes
cmd := exec.Command("hdiutil", "attach", "-nomount", fmt.Sprintf("ram://%d", int(size)))
out, err := cmd.Output() // /dev/diskN
log.Printf("%q\n", string(out))
diskpath := strings.TrimRight(string(out[:]), " \t\n")
log.Printf("%q\n", diskpath)
log.Printf("hdiutil attach ret %v. out: %s", err, diskpath)
// format as hfs
out, err = exec.Command("newfs_hfs", "-s", diskpath).Output()
log.Printf("newfs_hfs ret %v. out: %s", err, out)
// "posix" mount takes `struct hfs_mount_args` which we dont have bindings for at hand.
// See https://stackoverflow.com/a/49048846/4108673
// err = unix.Mount("hfs", mountpoint, unix.MNT_NOEXEC|unix.MNT_NODEV, mount_args)
// Instead we call:
out, err = exec.Command("mount", "-t", "hfs", "-o", "nobrowse,nodev,nosuid,-m=0751", diskpath, mountpoint).Output()
log.Printf("mount ret %v. out: %s", err, out)
// There is no documented way to check for memfs mountpoint. Thus we place a file.
path := mountpoint + "/sops-nix-secretfs"