feat: add sops
We've previously used SOPS for our secrets management and liked it. We
did, however, find the configuration was a bit annoying to do. In aid of
this, we've made a SOPS module that we find a little easier to make the
sort of configurations we want without creating so much mess.
We haven't set up scalpel/equivalent yet - we intend to avoid it if at
all possible. It isn't necessarily out-of-scope but it isn't included in
our current SOPS plans.
Change-Id: I35b9c7e94c12a4f1360833026efe06803d59626e
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/725
Reviewed-by: Samuel Shuert <coded@clicks.codes>
Tested-by: Samuel Shuert <coded@clicks.codes>
diff --git a/modules/nixos/clicks/secrets/README.md b/modules/nixos/clicks/secrets/README.md
new file mode 100644
index 0000000..83f43b9
--- /dev/null
+++ b/modules/nixos/clicks/secrets/README.md
@@ -0,0 +1,62 @@
+<!--
+SPDX-FileCopyrightText: 2024 Clicks Codes
+
+SPDX-License-Identifier: GPL-3.0-only
+-->
+
+# Clicks SOPS
+
+To create a secret you can do the following:
+
+```nix
+clicks.secrets."${lib.clicks.secrets.name ./headscale.sops.json}" = {
+ file = ./headscale.sops.json;
+ group = "headscale";
+ keys = [
+ "oidc_client_secret"
+ "database_password"
+ "noise_private_key"
+ "private_key"
+ ];
+ neededForUsers = false;
+};
+```
+The secret name is based on the secret file's hash.
+`file` is a path to the secrets file. It is required.
+`group` is the group the key should be owned by. We chose to use groups instead of users so that you can allow multiple
+different users to read the file. If you don't set it, we'll use `"root`.
+`keys` is a list of the keys of the secret file, assuming it's not a binary file. If it isn't a binary file, you are
+required to set this. If it is a binary file, you shouldn't specify this.
+`neededForUsers` requires the secret to be present before users are created on boot, it's identical to the sops option
+of the same name. Use it for user passwords. If you don't specify it, we'll use `false`.
+
+---
+
+You can then refer to the different keys directly from the secret, no need to manually create individual files:
+
+```nix
+client_secret_path = config.clicks.secrets."${lib.clicks.secrets.name ./headscale.sops.json}".paths.oidc_client_secret;
+```
+
+If the secret file is a binary file, the path can be accessed via
+
+```nix
+private_key = config.clicks.secrets."${lib.clicks.secrets.name ./privatekey.bin}".path;
+```
+
+---
+
+We recommend using `lib.clicks.secrets.name` with your path to name your secrets. This avoids you creating naming
+conflicts or having messy names. This is not a hard requirement for using the module outside of Clicks, but if you're
+contributing to Clicks infrastructure we will enforce this at review.
+
+This takes a path, and is guarenteed to be stable when passed the same file at the same path.
+
+```nix
+lib.clicks.secrets.name ./file.sops.json
+```
+
+---
+
+In Clicks, secrets are only ever encrypted to a single host. You'll need to make the secrets within the
+`systems/<arch>/<hostname>` directory to let sops know what host to encrypt to.
diff --git a/modules/nixos/clicks/secrets/default.nix b/modules/nixos/clicks/secrets/default.nix
new file mode 100644
index 0000000..19f01b4
--- /dev/null
+++ b/modules/nixos/clicks/secrets/default.nix
@@ -0,0 +1,138 @@
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+let
+ cfg = config.clicks.security.sops;
+
+ guessFormat =
+ extension:
+ if extension == "json" then
+ "json"
+ else if extension == "yaml" || extension == "yml" then
+ "yaml"
+ else if extension == "env" then
+ "dotenv"
+ else if extension == "ini" then
+ "ini"
+ else
+ "binary";
+
+ getExtension =
+ filePath:
+ let
+ pathParts = builtins.split ''\.'' (builtins.toString filePath);
+ numPathParts = builtins.length pathParts;
+ in
+ builtins.elemAt pathParts (numPathParts - 1);
+in
+{
+ options.clicks.secrets =
+ let
+ generateNonBinarySopsPaths =
+ file: keys:
+ lib.lists.forEach keys (key: {
+ name = key;
+ value = config.sops.secrets."${lib.clicks.secrets.name file}:${key}".path;
+ });
+ in
+ lib.mkOption {
+ type = lib.types.attrsOf (
+ lib.types.submodule (
+ { ... }@submodule:
+ {
+ options = {
+ file = lib.mkOption {
+ type = lib.types.pathInStore;
+ description = "The store path to your secrets file";
+ };
+ group = lib.mkOption {
+ type = lib.types.str;
+ description = "The user the secret should be owned by.";
+ default = "root";
+ };
+ keys = lib.mkOption {
+ type = lib.types.nullOr (lib.types.listOf lib.types.str);
+ description = "List of keys to pull from the structured data.";
+ default = null;
+ };
+ neededForUsers = lib.mkEnableOption "This secret is needed for users";
+ paths = lib.mkOption {
+ type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+ description = "Automatically populated with the SOPS paths to your keys, null if you are using binary secrets";
+ default =
+ if guessFormat (getExtension submodule.config.file) != "binary" then
+ builtins.listToAttrs (generateNonBinarySopsPaths submodule.config.file submodule.config.keys)
+ else
+ null;
+ };
+ path = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ description = "Populated automatically with the SOPS path of the secret, null if you are using non binary secrets";
+ default =
+ if guessFormat (getExtension submodule.config.file) == "binary" then
+ config.sops.secrets.${lib.clicks.secrets.name submodule.config.file}.path
+ else
+ null;
+ };
+ };
+ }
+ )
+ );
+ description = "";
+ default = { };
+ };
+
+ config =
+ let
+ generateBinarySopsSecret = secret: {
+ name = lib.clicks.secrets.name secret.value.file;
+ value = {
+ mode = "0400";
+ owner = config.users.users.root.name;
+ group = config.users.groups.${secret.value.group}.name;
+ sopsFile = secret.value.file;
+ format = guessFormat (getExtension secret.value.file);
+ inherit (secret.value) neededForUsers;
+ };
+ };
+
+ generateNonBinarySopsSecrets =
+ secret:
+ lib.lists.forEach secret.value.keys (key: {
+ name = "${lib.clicks.secrets.name secret.value.file}:${key}";
+ value = {
+ mode = "0040";
+ owner = config.users.users.root.name;
+ group = config.users.groups.${secret.value.group}.name;
+ sopsFile = secret.value.file;
+ format = guessFormat (getExtension secret.value.file);
+ inherit (secret.value) neededForUsers;
+ inherit key;
+ };
+ });
+
+ secretsAsList = lib.attrsets.attrsToList config.clicks.secrets;
+
+ secretsAsSops = lib.pipe secretsAsList [
+ (map (
+ secret:
+ if guessFormat (getExtension secret.value.file) == "binary" then
+ generateBinarySopsSecret secret
+ else
+ generateNonBinarySopsSecrets secret
+ ))
+ lib.flatten
+ builtins.listToAttrs
+ ];
+ in
+ {
+ sops.secrets = secretsAsSops;
+ };
+}