diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a51426..2a0be67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - `shb.authelia.oidcClients.secret` -> `shb.authelia.oidcClients.client_secret` - `shb.authelia.ldapEndpoint` -> `shb.authelia.ldapHostname` and `shb.authelia.ldapPort` - Make Nextcloud automatically disable maintenance mode upon service restart. +- `shb.ldap.ldapUserPasswordFile` -> `shb.ldap.ldapUserPassword.result.path` +- `shb.ldap.jwtSecretFile` -> `shb.ldap.jwtSecret.result.path` ## User Facing Backwards Compatible Changes diff --git a/modules/blocks/ldap.nix b/modules/blocks/ldap.nix index ffc8d75..25c40e9 100644 --- a/modules/blocks/ldap.nix +++ b/modules/blocks/ldap.nix @@ -47,42 +47,20 @@ in default = 17170; }; - ldapUserPasswordFile = lib.mkOption { - type = lib.types.path; - description = "File containing the LDAP admin user password."; + ldapUserPassword = contracts.secret.mkOption { + description = "LDAP admin user secret."; + mode = "0440"; + owner = "lldap"; + group = "lldap"; + restartUnits = [ "lldap.service" ]; }; - jwtSecretFile = lib.mkOption { - type = lib.types.path; - description = "File containing the JWT secret."; - }; - - secret = { - ldapUserPasswordFile = lib.mkOption { - type = contracts.secret; - description = '' - Secret configuration for the file containing the LDAP admin user password. - ''; - default = { - mode = "0440"; - owner = "lldap"; - group = "lldap"; - restartUnits = [ "lldap.service" ]; - }; - }; - - jwtSecretFile = lib.mkOption { - type = contracts.secret; - description = '' - Secret configuration for the file containing the JWT secret. - ''; - default = { - mode = "0440"; - owner = "lldap"; - group = "lldap"; - restartUnits = [ "lldap.service" ]; - }; - }; + jwtSecret = contracts.secret.mkOption { + description = "JWT secret."; + mode = "0440"; + owner = "lldap"; + group = "lldap"; + restartUnits = [ "lldap.service" ]; }; restrictAccessIPRange = lib.mkOption { @@ -174,8 +152,8 @@ in enable = true; environment = { - LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecretFile; - LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPasswordFile; + LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecret.result.path; + LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPassword.result.path; RUST_LOG = lib.mkIf cfg.debug "debug"; }; diff --git a/modules/contracts/secret.nix b/modules/contracts/secret.nix index 217da53..7ab3ccc 100644 --- a/modules/contracts/secret.nix +++ b/modules/contracts/secret.nix @@ -1,38 +1,101 @@ { lib, ... }: -lib.types.submodule { - freeformType = lib.types.anything; +{ + mkOption = + { description, + mode ? "0400", + owner ? "root", + group ? "root", + restartUnits ? [], + }: lib.mkOption { + inherit description; - options = { - mode = lib.mkOption { - description = '' - Mode of the secret file. - ''; - type = lib.types.str; - default = "0400"; - }; + type = lib.types.submodule { + options = { + request = lib.mkOption { + default = { + inherit mode owner group restartUnits; + }; - owner = lib.mkOption { - description = '' - Linux user owning the secret file. - ''; - type = lib.types.str; - default = "root"; - }; + readOnly = true; - group = lib.mkOption { - description = '' - Linux group owning the secret file. - ''; - type = lib.types.str; - default = "root"; - }; + description = '' + Options set by the requester module + enforcing some properties the secret should have. - restartUnits = lib.mkOption { - description = '' - Systemd units to restart after the secret is updated. - ''; - type = lib.types.listOf lib.types.str; - default = []; + Use the `contracts.secret.mkOption` function to + create a secret option for a requester module. + See the [requester usage section](contracts-secret.html#secret-contract-usage-requester) for an example. + + Some providers will need more options to be defined and this is allowed. + These extra options will be set by the user. + For example, the `sops` implementation requires to be given + the sops key in which the secret is encrypted. + + `request` options are set read-only + because they must be set through option defaults, + they shouldn't be changed in the `config` section. + This would otherwise lead to infinite recursion + during evaluation. + This is handled automatically when using the `contracts.secret.mkOption` function. + ''; + type = lib.types.submodule { + freeformType = lib.types.anything; + + options = { + mode = lib.mkOption { + description = '' + Mode of the secret file. + ''; + type = lib.types.str; + default = mode; + }; + + owner = lib.mkOption { + description = '' + Linux user owning the secret file. + ''; + type = lib.types.str; + default = owner; + }; + + group = lib.mkOption { + description = '' + Linux group owning the secret file. + ''; + type = lib.types.str; + default = group; + }; + + restartUnits = lib.mkOption { + description = '' + Systemd units to restart after the secret is updated. + ''; + type = lib.types.listOf lib.types.str; + default = restartUnits; + }; + }; + }; + }; + + result = lib.mkOption { + description = '' + Options set by the provider module that indicates where the secret can be found. + ''; + type = lib.types.submodule { + options = { + path = lib.mkOption { + type = lib.types.path; + description = '' + Path to the file containing the secret generated out of band. + + This path will exist after deploying to a target host, + it is not available through the nix store. + ''; + }; + }; + }; + }; + }; + }; }; - }; } diff --git a/modules/contracts/secret/docs/default.md b/modules/contracts/secret/docs/default.md index 44fb517..3fd1e8c 100644 --- a/modules/contracts/secret/docs/default.md +++ b/modules/contracts/secret/docs/default.md @@ -1,100 +1,15 @@ # Secret Contract {#secret-contract} This NixOS contract represents a secret file -that must be created out of band, from outside the nix store, +that must be created out of band - from outside the nix store - and that must be placed in an expected location with expected permission. -It is a contract between a service that needs a secret -and a service that will provide the secret. -All options in this contract should be set by the former. -The latter will then use the values of those options to know where to produce the file. +More formally, this contract is made between a requester module - the one needing a secret - +and a provider module - the one creating the secret and making it available. -## Contract Reference {#secret-contract-options} +## Problem Statement {#secret-contract-problem} -These are all the options that are expected to exist for this contract to be respected. - -```{=include=} options -id-prefix: contracts-secret-options- -list-id: selfhostblocks-options -source: @OPTIONS_JSON@ -``` - -## Usage {#secret-contract-usage} - -A service that needs access to a secret will provide one or more `secret` option. - -Here is an example module defining two `secret` options: - -```nix -{ - options = { - myservice.secret = lib.mkOption { - type = lib.types.submodule { - options = { - adminPassword = lib.mkOption { - type = contracts.secret; - readOnly = true; - default = { - owner = "myservice"; - group = "myservice"; - mode = "0440"; - restartUnits = [ "myservice.service" ]; - }; - }; - databasePassword = lib.mkOption { - type = contracts.secret; - readOnly = true; - default = { - owner = "myservice"; - restartUnits = [ "myservice.service" "mysql.service" ]; - }; - }; - }; - }; - }; - }; -}; -``` - -As you can see, NixOS modules are a bit abused to make contracts work. -Default values are set as well as the `readOnly` attribute to ensure those values stay as defined. - -Now, on the other side we have a service that uses these `secret` options and provides the secrets -Let's assume such a module is available under the `secretservice` option -and that one can create multiple instances under `secretservice.instances`. -Then, to actually provide the secrets defined above, one would write: - -```nix -secretservice.instances.adminPassword = myservice.secret.adminPassword // { - enable = true; - - secretFile = ./secret.yaml; - - # ... Other options specific to secretservice. -}; - -secretservice.instances.databasePassword = myservice.secret.databasePassword // { - enable = true; - - secretFile = ./secret.yaml; - - # ... Other options specific to secretservice. -}; -``` - -Assuming the `secretservice` module accepts default options, -the above snippet could be reduced to: - -```nix -secretservice.default.secretFile = ./secret.yaml; - -secretservice.instances.adminPassword = myservice.secret.adminPassword; -secretservice.instances.databasePassword = myservice.secret.databasePassword; -``` - -### With sops-nix {#secret-contract-usage-sopsnix} - -For a concrete example, let's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile` +Let's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile` with a secret managed by [sops-nix][]. [ldap-module]: TODO @@ -114,24 +29,26 @@ sops.secrets."ldap/user_password" = { shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path; ``` -We can already see the problem here. -How does the end user know what values to give to the +The problem this contract intends to fix is how to ensure +the end user knows what values to give to the `mode`, `owner`, `group` and `restartUnits` options? + If lucky, the documentation of the option would tell them or more likely, they will need to figure it out by looking -at the module source code. Not a great user experience. +at the module source code. +Not a great user experience. Now, with this contract, the configuration becomes: ```nix -sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPasswordFile // { +sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPassword.request // { sopsFile = ./secrets.yaml; }; -shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path; +shb.ldap.ldapUserPassword.result.path = config.sops.secrets."ldap/user_password".path; ``` -The issue is now gone. +The issue is now gone at the expense of some plumbing. The module maintainer is now in charge of describing how the module expects the secret to be provided. @@ -144,7 +61,148 @@ sops.defaultSopsFile = ./secrets.yaml; Then the snippet above is even more simplified: ```nix -sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPasswordFile; +sops.secrets."ldap/user_password" = config.shb.ldap.secret.ldapUserPassword.request; -shb.ldap.ldapUserPasswordFile = config.sops.secrets."ldap/user_password".path; +shb.ldap.ldapUserPassword.result.path = config.sops.secrets."ldap/user_password".path; +``` + +## Contract Reference {#secret-contract-options} + +These are all the options that are expected to exist for this contract to be respected. + +```{=include=} options +id-prefix: contracts-secret-options- +list-id: selfhostblocks-options +source: @OPTIONS_JSON@ +``` + +## Usage {#secret-contract-usage} + +A contract involves 3 parties: + +- The implementer of a requester module. +- The implementer of a provider module. +- The end user which sets up the requester module and picks a provider implementation. + +The usage of this contract is similarly separated into 3 sections. + +### Requester Module {#secret-contract-usage-requester} + +Here is an example module requesting two secrets through the `secret` contract. + +```nix +{ config, ... }: +{ + options = { + myservice = lib.mkOption { + type = lib.types.submodule { + options = { + adminPassword = contracts.secret.mkOption { + owner = "myservice"; + group = "myservice"; + mode = "0440"; + restartUnits = [ "myservice.service" ]; + }; + databasePassword = contracts.secret.mkOption { + owner = "myservice"; + # group defaults to "root" + # mode defaults to "0400" + restartUnits = [ "myservice.service" "mysql.service" ]; + }; + }; + }; + }; + }; + + config = { + // Do something with the secrets, available at: + // config.myservice.adminPassword.result.path + // config.myservice.databasePassword.result.path + }; +}; +``` + +### Provider Module {#secret-contract-usage-provider} + +Now, on the other side, we have a module that uses those options and provides a secret. +Let's assume such a module is available under the `secretservice` option +and that one can create multiple instances. + +```nix +{ config, ... }: +{ + options = { + secretservice = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + mode = lib.mkOption { + description = "Mode of the secret file."; + type = lib.types.str; + }; + + owner = lib.mkOption { + description = "Linux user owning the secret file."; + type = lib.types.str; + }; + + group = lib.mkOption { + description = "Linux group owning the secret file."; + type = lib.types.str; + }; + + restartUnits = lib.mkOption { + description = "Systemd units to restart after the secret is updated."; + type = lib.types.listOf lib.types.str; + }; + + path = lib.mkOption { + description = "Path where the secret file will be located."; + type = lib.types.str; + }; + + // The contract allows more options to be defined to accomodate specific implementations. + secretFile = lib.mkOption { + description = "File containing the encrypted secret."; + type = lib.types.path; + }; + }; + }); + }; + }; +} +``` + +### End User {#secret-contract-usage-enduser} + +The end user's responsibility is now to do some plumbing. + +They will setup the provider module - here `secretservice` - with the options set by the requester module, +while also setting other necessary options to satisfy the provider service. + +```nix +secretservice.adminPassword = myservice.secret.adminPassword.request // { + secretFile = ./secret.yaml; +}; + +secretservice.databasePassword = myservice.secret.databasePassword.request // { + secretFile = ./secret.yaml; +}; +``` + +Assuming the `secretservice` module accepts default options, +the above snippet could be reduced to: + +```nix +secretservice.default.secretFile = ./secret.yaml; + +secretservice.adminPassword = myservice.secret.adminPassword.request; +secretservice.databasePassword = myservice.secret.databasePassword.request; +``` + +Then they will setup the requester module - here `myservice` - with the result of the provider module. + +```nix +myservice.secret.adminPassword.result.path = secretservice.adminPassword.result.path; + +myservice.secret.databasePassword.result.path = secretservice.adminPassword.result.path; ``` diff --git a/modules/contracts/secret/dummyModule.nix b/modules/contracts/secret/dummyModule.nix index d884b89..7a998be 100644 --- a/modules/contracts/secret/dummyModule.nix +++ b/modules/contracts/secret/dummyModule.nix @@ -3,8 +3,19 @@ let contracts = pkgs.callPackage ../. {}; in { - options.shb.contracts.secret = lib.mkOption { - description = "Contract for secrets."; - type = contracts.secret; + options.shb.contracts.secret = contracts.secret.mkOption { + description = '' + Contract for secrets between a requester module + and a provider module. + + The requester communicates to the provider + some properties the secret should have + through the `request` options. + + The provider reads from the `request` options + and creates the secret as requested. + It then communicates to the requester where the secret can be found + through the `result` options. + ''; }; } diff --git a/test/blocks/authelia.nix b/test/blocks/authelia.nix index 6b01f31..ec6d17f 100644 --- a/test/blocks/authelia.nix +++ b/test/blocks/authelia.nix @@ -32,8 +32,8 @@ in dcdomain = "dc=example,dc=com"; subdomain = "ldap"; domain = "machine.com"; - ldapUserPasswordFile = pkgs.writeText "user_password" ldapAdminPassword; - jwtSecretFile = pkgs.writeText "jwt_secret" "securejwtsecret"; + ldapUserPassword.result.path = pkgs.writeText "user_password" ldapAdminPassword; + jwtSecret.result.path = pkgs.writeText "jwt_secret" "securejwtsecret"; }; shb.authelia = { diff --git a/test/blocks/ldap.nix b/test/blocks/ldap.nix index 2b67cbd..b158939 100644 --- a/test/blocks/ldap.nix +++ b/test/blocks/ldap.nix @@ -23,8 +23,8 @@ in dcdomain = "dc=example,dc=com"; subdomain = "ldap"; domain = "example.com"; - ldapUserPasswordFile = pkgs.writeText "user_password" "securepw"; - jwtSecretFile = pkgs.writeText "jwt_secret" "securejwtsecret"; + ldapUserPassword.result.path = pkgs.writeText "user_password" "securepw"; + jwtSecret.result.path = pkgs.writeText "jwt_secret" "securejwtsecret"; debug = true; }; networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port diff --git a/test/common.nix b/test/common.nix index b626bd0..40d512f 100644 --- a/test/common.nix +++ b/test/common.nix @@ -154,8 +154,8 @@ in ldapPort = 3890; webUIListenPort = 17170; dcdomain = "dc=example,dc=com"; - ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; - jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + ldapUserPassword.result.path = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + jwtSecret.result.path = pkgs.writeText "jwtSecret" "jwtSecret"; }; }; diff --git a/test/services/forgejo.nix b/test/services/forgejo.nix index 8cd2060..6a15c66 100644 --- a/test/services/forgejo.nix +++ b/test/services/forgejo.nix @@ -57,7 +57,7 @@ let host = "127.0.0.1"; port = config.shb.ldap.ldapPort; dcdomain = config.shb.ldap.dcdomain; - adminPasswordFile = config.shb.ldap.ldapUserPasswordFile; + adminPasswordFile = config.shb.ldap.ldapUserPassword.result.path; }; }; }; diff --git a/test/services/jellyfin.nix b/test/services/jellyfin.nix index 3fd3474..2561456 100644 --- a/test/services/jellyfin.nix +++ b/test/services/jellyfin.nix @@ -43,7 +43,7 @@ let host = "127.0.0.1"; port = config.shb.ldap.ldapPort; dcdomain = config.shb.ldap.dcdomain; - passwordFile = config.shb.ldap.ldapUserPasswordFile; + passwordFile = config.shb.ldap.ldapUserPassword.result.path; }; }; }; diff --git a/test/services/nextcloud.nix b/test/services/nextcloud.nix index b666cd2..e4dfaa2 100644 --- a/test/services/nextcloud.nix +++ b/test/services/nextcloud.nix @@ -152,7 +152,7 @@ let port = config.shb.ldap.ldapPort; dcdomain = config.shb.ldap.dcdomain; adminName = "admin"; - adminPasswordFile = config.shb.ldap.ldapUserPasswordFile; + adminPasswordFile = config.shb.ldap.ldapUserPassword.result.path; userGroup = "nextcloud_user"; }; };