diff --git a/modules/blocks/ldap.nix b/modules/blocks/ldap.nix index e0a5b55..9ff1859 100644 --- a/modules/blocks/ldap.nix +++ b/modules/blocks/ldap.nix @@ -1,6 +1,9 @@ { config, pkgs, lib, ... }: let + inherit (lib) mkOption; + inherit (lib.types) attrsOf str submodule; + cfg = config.shb.ldap; contracts = pkgs.callPackage ../contracts {}; @@ -149,16 +152,42 @@ in }; groups = lib.mkOption { - description = "LDAP Groups to manage declaratively."; + description = "LDAP Groups to manage declaratively following the [ldap group contract](./contracts-ldap-group.html)."; default = {}; example = lib.literalExpression '' { family = {}; } ''; - type = lib.types.attrsOf (lib.types.submodule { - options = {}; - }); + type = attrsOf (submodule ({ name, config, ... }: { + options = contracts.ldapgroup.mkProvider { + settings = mkOption { + description = '' + Settings specific to the LLDAP provider. + + By default it is the same as the field name. + ''; + default = { + inherit name; + }; + + type = submodule { + options = { + name = mkOption { + description = "Name of the LDAP group"; + type = str; + default = name; + }; + }; + }; + }; + + resultCfg = { + name = config.settings.name; + nameText = name; + }; + }; + })); }; deleteUnmanagedUsers = lib.mkOption { @@ -372,8 +401,6 @@ in systemd.services.lldap.postStart = let - configFile = (pkgs.formats.toml {}).generate "lldap_config.toml" config.services.lldap.settings; - login = ['' set -euo pipefail @@ -388,7 +415,7 @@ in deleteGroups = ['' allUids=(${lib.concatStringsSep " " ( - (lib.mapAttrsToList (id: g: id) cfg.groups) + (lib.mapAttrsToList (id: g: g.settings.name) cfg.groups) ++ [ "lldap_admin" "lldap_password_manager" "lldap_strict_readonly" ]) }) echo All managed groups are: ''${allUids[*]} @@ -405,7 +432,7 @@ in createGroups = ['' existingUids=$(${lldap-cli}/bin/lldap-cli group list | ${pkgs.jq}/bin/jq -r 'map(.displayName)[]') managedUids=(${lib.concatStringsSep " " ( - (lib.mapAttrsToList (id: g: id) cfg.groups) + (lib.mapAttrsToList (id: g: g.settings.name) cfg.groups) ++ [ "lldap_admin" "lldap_password_manager" "lldap_strict_readonly" ]) }) echo All managed groups are: ''${managedUids[*]} diff --git a/modules/blocks/ldap/docs/default.md b/modules/blocks/ldap/docs/default.md new file mode 100644 index 0000000..4c2afdf --- /dev/null +++ b/modules/blocks/ldap/docs/default.md @@ -0,0 +1,133 @@ +# LLDAP Block {#blocks-lldap} + +Defined in [`/modules/blocks/lldap.nix`](@REPO@/modules/blocks/lldap.nix). + +This block sets up a LDAP server using [LLDAP][]. + +[LLDAP]: https://github.com/lldap/lldap + +## Tests {#blocks-lldap-tests} + +Specific integration tests are defined in [`/test/blocks/lldap.nix`](@REPO@/test/blocks/lldap.nix). +The tests use a neat trick using specialization and switching configuration +to make sure changing the declarative configuration has the expected result. + +## Provider Contracts {#blocks-lldap-contract-provider} + +This block provides the following contract: + +- [groups ldap contract](contracts-groups-ldap.html) under the [`shb.ldap.groups`][groups] option. + It is tested with [contract tests][groups ldap contract tests]. + +[groups]: #blocks-lldap-options-shb.ldap.groups +[groups ldap contract tests]: @REPO@/test/contracts/groups-ldap.nix + +## Usage {#blocks-lldap-usage} + +### Manage groups {#blocks-lldap-usage-groups} + +The following snippet will create group named "family" if it does not exist yet. +Also, all other groups will be deleted and only the "family" group will remain. + +Note that the "admin" group, which is internal to LLDAP, will never be deleted. + +```nix +{ + shb.ldap.groups = { + family = {}; + }; +} +``` + +Changing the configuration to the following will add a new group "friends": + +```nix +{ + shb.ldap.groups = { + family = {}; + friends = {}; + }; +} +``` + +Switching back the configuration to the previous one will delete the group "friends": + +```nix +{ + shb.ldap.groups = { + family = {}; + }; +} +``` + +Currently, only the empty attrset is supported as the value for a group. +This will change in the future when LLDAP supports custom group attributes. + +### Manage users {#blocks-lldap-usage-users} + +The following snippet creates a user and makes it a member of the "family" group. + +```nix +{ + shb.ldap.users = { + dad = { + email = "dad@example.com"; + displayName = "Dad"; + firstName = "First Name"; + lastName = "Last Name"; + groups = [ "family" ]; + password.result = config.shb.sops.secret."dad".result; + }; + }; + + shb.sops.secret."dad".request = + shb.ldap.users.dad.password.request; +} +``` + +The password field assumes usage of the [sops block][] to provide secrets +although any blocks providing the [secrets contract][] works too. + +[sops block]: ./blocks-sops.html +[secrets contract]: ./contracts-secrets.html + +The user is still editable through the UI. +That being said, any change will be overwritten next time the configuration is applied. +If instead you just want to set initial values, +there are fields for that: + +```nix +{ + shb.ldap.users = { + dad = { + initialEmail = "dad@example.com"; + initialDisplayName = "Dad"; + initialFirstName = "First Name"; + initialLastName = "Last Name"; + initialGroups = [ "family" ]; + initialPassword.result = config.shb.sops.secret."dad".result; + }; + }; + + shb.sops.secret."dad".request = + shb.ldap.users.dad.password.request; +} +``` + +Also, all fields apart from the email are optional, even the password. + +Adding or removing groups to the `shb.ldap.users..groups` will make the user member of the groups listed in the option. + +## Troubleshooting {#blocks-lldap-troubleshooting} + +To see the logs, run `journalctl -u lldap.service`. + +To see the trace of the GraphQL queries, set `shb.ldap.debug = true;`. + +## Options Reference {#blocks-lldap-options} + +```{=include=} options +id-prefix: blocks-lldap-options- +list-id: selfhostblocks-block-lldap-options +source: @OPTIONS_JSON@ +``` diff --git a/modules/contracts/databasebackup/docs/default.md b/modules/contracts/databasebackup/docs/default.md index f9f7384..760d341 100644 --- a/modules/contracts/databasebackup/docs/default.md +++ b/modules/contracts/databasebackup/docs/default.md @@ -19,29 +19,39 @@ source: @OPTIONS_JSON@ ## Usage {#contract-databasebackup-usage} -A database that can be backed up will provide a `databasebackup` option. -Such a service is a `requester` providing a `request` for a module `provider` of this contract. - -What this option defines is, from the user perspective - that is _you_ - an implementation detail +What this contract defines is, from the user perspective - that is _you_ - an implementation detail but it will at least define how to create a database dump, the user to backup with and how to restore from a database dump. +A NixOS module that can be backed up using this contract will provide a `databasebackup` option. +Such a service is a `requester` which has a `request`. + Here is an example module defining such a `databasebackup` option: ```nix { options = { - myservice.databasebackup = mkOption { - type = contracts.databasebackup.request; - default = { - user = "myservice"; - backupCmd = '' - ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable - ''; - restoreCmd = '' - ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres - ''; + databasebackupservices.instances = mkOption { + description = '' + Backup configuration. + ''; + + default = {}; + type = submodule { + options = contracts.databasebackup.mkRequester { + user = "postgres"; + + backupName = "postgres.sql"; + + backupCmd = '' + ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable + ''; + + restoreCmd = '' + ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres + ''; + }; }; }; }; @@ -51,28 +61,60 @@ Here is an example module defining such a `databasebackup` option: Now, on the other side we have a service that uses this `backup` option and actually backs up files. This service is a `provider` of this contract and will provide a `result` option. -Let's assume such a module is available under the `databasebackupservice` option -and that one can create multiple backup instances under `databasebackupservice.instances`. -Then, to actually backup the `myservice` service, one would write: +```nix +{ + options = { + instances = mkOption { + description = "Files to backup."; + default = {}; + type = attrsOf (submodule ({ name, config, ... }: { + options = contracts.backup.mkProvider { + settings = mkOption { + description = '' + Settings specific to the this provider. + ''; + + type = submodule { + options = { + enable = mkEnableOption "this backup intance."; + # ... Other options specific to this provider. + }; + }; + }; + + resultCfg = let + fullName = name: repository: "backups-${name}_${repository.path}"; + in { + restoreScript = fullName name config.settings.repository; + restoreScriptText = "${fullName "" { path = "path/to/repository"; }}"; + + backupService = "${fullName name config.settings.repository}.service"; + backupServiceText = "${fullName "" { path = "path/to/repository"; }}.service"; + }; + }; + })); + }; + }; +} +``` + +Then, to actually backup the `myservice` service, +one would need to link the requester to the provider with: ```nix databasebackupservice.instances.myservice = { - request = myservice.databasebackup; + request = config.myservice.databasebackup.request; settings = { enable = true; - repository = { - path = "/srv/backup/myservice"; - }; - - # ... Other options specific to backupservice like scheduling. + # ... Other options specific to this provider. }; }; ``` It is advised to backup files to different location, to improve redundancy. -Thanks to using contracts, this can be made easily either with the same `databasebackupservice`: +Thanks to using contracts, this can be done easily either with the same `databasebackupservice`: ```nix databasebackupservice.instances.myservice_2 = { diff --git a/modules/contracts/default.nix b/modules/contracts/default.nix index 889bcd6..2dd6027 100644 --- a/modules/contracts/default.nix +++ b/modules/contracts/default.nix @@ -47,6 +47,7 @@ in { databasebackup = importContract ./databasebackup.nix; backup = importContract ./backup.nix; + ldapgroup = importContract ./ldapgroup.nix; mount = import ./mount.nix { inherit lib; }; secret = importContract ./secret.nix; ssl = import ./ssl.nix { inherit lib; }; diff --git a/modules/contracts/ldapgroup.nix b/modules/contracts/ldapgroup.nix new file mode 100644 index 0000000..af25cec --- /dev/null +++ b/modules/contracts/ldapgroup.nix @@ -0,0 +1,83 @@ +{ pkgs, lib, ... }: +let + inherit (lib) literalMD mkOption optionalAttrs optionalString; + inherit (lib.types) str submodule; + + shblib = pkgs.callPackage ../../lib {}; + inherit (shblib) anyNotNull; +in +{ + mkRequest = { + name ? "", + nameText ? null, + }: mkOption { + description = '' + Request part of the ldap group contract. + + Options set by the requester module + enforcing what properties the group should have. + + This is intentionally empty as the only required property + is the existence of this option. + ''; + default = { + inherit name; + }; + defaultText = optionalString (anyNotNull [ + nameText + ]) (literalMD '' + { + name = ${if nameText != null then nameText else name}; + } + ''); + + type = submodule { + options = { + name = mkOption { + description = '' + Name of the LDAP group. + ''; + type = str; + default = name; + } // optionalAttrs (nameText != null) { + defaultText = literalMD nameText; + }; + }; + }; + }; + + mkResult = { + name ? "", + nameText ? null, + }: mkOption { + description = '' + Result part of the ldap group contract. + + Options set by the provider module that indicates the name of the group and other properties. + ''; + default = { + inherit name; + }; + defaultText = optionalString (anyNotNull [ + nameText + ]) (literalMD '' + { + name = ${if nameText != null then nameText else name}; + } + ''); + + type = submodule { + options = { + name = mkOption { + description = '' + Name of the LDAP group. + ''; + type = str; + default = name; + } // optionalAttrs (nameText != null) { + defaultText = literalMD nameText; + }; + }; + }; + }; +} diff --git a/modules/contracts/ldapgroup/docs/default.md b/modules/contracts/ldapgroup/docs/default.md new file mode 100644 index 0000000..a137181 --- /dev/null +++ b/modules/contracts/ldapgroup/docs/default.md @@ -0,0 +1,124 @@ +# LDAP Group Contract {#contract-ldapgroup} + +This NixOS contract represents an LDAP group +that must be created. + +It is a contract between a service that needs an LDAP group +and a service that can provide such a group. + +## Contract Reference {#contract-ldapgroup-options} + +These are all the options that are expected to exist for this contract to be respected. + +```{=include=} options +id-prefix: contracts-ldapgroup-options- +list-id: selfhostblocks-options +source: @OPTIONS_JSON@ +``` + +## Usage {#contract-ldapgroup-usage} + +What this contract defines is, from the user perspective - that is _you_ - an implementation detail +but you can know it simply defines the LDAP group name. + +A NixOS module that needs a LDAP group using this contract will provide a `ldapgroup` option or similarly named. +Such a service is a `requester` providing a `request` for a module `provider` of this contract. + +Here is an example module defining such a `ldapgroup` option: + +```nix +{ + options = { + myservice.ldapgroup = mkOption { + type = contracts.ldapgroup.request; + }; + }; +}; +``` + +Now, on the other side we have a service that uses this `ldapgroup` option and actually creates the LDAP group. +This service is a `provider` of this contract and will provide a `result` option containing the name of the group. + +```nix +{ + options = { + ldap.groups = lib.mkOption { + description = "LDAP Groups to manage declaratively."; + default = {}; + example = lib.literalExpression '' + { + family = {}; + } + ''; + type = attrsOf (submodule ({ name, config, ... }: { + options = contracts.ldapgroup.mkProvider { + settings = mkOption { + description = '' + Settings specific to the LLDAP provider. + + By default it is the same as the field name. + ''; + default = { + inherit name; + }; + + type = submodule { + options = { + name = mkOption { + description = "Name of the LDAP group"; + type = str; + default = name; + }; + }; + }; + }; + + resultCfg = { + name = config.settings.name; + nameText = name; + }; + }; + })); + }; + }; +}; +``` + +Then, to actually backup the `myservice` service, +one would need to link the requester to the provider with: + +```nix +# requester -> provider +ldapgroupservice.groups.myservice = { + request = config.myservice.ldapgroup.request; +}; +# provider -> requester +myservice.ldapgroup.result = config.ldapgroupservice.groups.myservice.result; +``` + +By default, the name of the LDAP group will be the same as the field name under the `groups` option. Here, `"myservice"`. + +Usually, a service will require two LDAP groups to work properly, +one for users and another one for admin users. +In this case, the linking them together will look like so: + +```nix +# requester -> provider +ldapgroupservice.groups = { + myservice_user.request = config.myservice.ldap.userGroup.request; + myservice_admin.request = config.myservice.ldap.adminGroup.request; +}; +# provider -> requester +myservice.ldap = { + userGroup.result = config.ldapgroupservice.groups.myservice_user.result; + adminGroup.result = config.ldapgroupservice.groups.myservice_admin.result; +}; +``` + +## Providers of the Database Backup Contract {#contract-ldapgroup-providers} + +- [LDAP block](blocks-ldap.html). + +## Requester Blocks and Services {#contract-ldapgroup-requesters} + +- [Nextcloud service](services-nextcloud.html#services-nextcloud-contract-ldapgroup). diff --git a/modules/services/forgejo.nix b/modules/services/forgejo.nix index 7399132..9876ced 100644 --- a/modules/services/forgejo.nix +++ b/modules/services/forgejo.nix @@ -98,15 +98,13 @@ in }; userGroup = mkOption { - type = str; + type = contracts.ldapgroup.request; description = "Group users must belong to be able to login."; - default = "forgejo_user"; }; adminGroup = mkOption { - type = str; + type = contracts.ldapgroup.request; description = "Group users must belong to be admins."; - default = "forgejo_admin"; }; }; }); @@ -394,8 +392,8 @@ in --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ - --user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ - --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ + --user-filter '(&(memberof=cn=${cfg.ldap.userGroup.result.name},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ + --admin-filter '(memberof=cn=${cfg.ldap.adminGroup.result.name},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ @@ -413,8 +411,8 @@ in --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ - --user-filter '(&(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ - --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ + --user-filter '(&(memberof=cn=${cfg.ldap.userGroup.result.name},ou=groups,${cfg.ldap.dcdomain})(|(uid=%[1]s)(mail=%[1]s)))' \ + --admin-filter '(memberof=cn=${cfg.ldap.adminGroup.result.name},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ diff --git a/modules/services/home-assistant.nix b/modules/services/home-assistant.nix index 21a6921..b2ba529 100644 --- a/modules/services/home-assistant.nix +++ b/modules/services/home-assistant.nix @@ -122,9 +122,8 @@ in }; userGroup = lib.mkOption { - type = lib.types.str; + type = contracts.ldapgroup.request; description = "Group users must belong to to be able to login to Nextcloud."; - default = "homeassistant_user"; }; keepDefaultAuth = lib.mkOption { @@ -241,7 +240,7 @@ in { type = "command_line"; command = ldap_auth_script + "/bin/ldap_auth.sh"; - args = [ "http://${cfg.ldap.host}:${toString cfg.ldap.port}" cfg.ldap.userGroup ]; + args = [ "http://${cfg.ldap.host}:${toString cfg.ldap.port}" cfg.ldap.userGroup.result.name ]; meta = true; } ]); diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index 804afac..d0f768f 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -56,15 +56,23 @@ in }; userGroup = lib.mkOption { - type = lib.types.str; description = "LDAP user group"; - default = "jellyfin_user"; + default = {}; + type = lib.types.submodule { + options = contracts.ldapgroup.mkRequester { + name = "jellyfin_user"; + }; + }; }; adminGroup = lib.mkOption { - type = lib.types.str; description = "LDAP admin group"; - default = "jellyfin_admin"; + default = {}; + type = lib.types.submodule { + options = contracts.ldapgroup.mkRequester { + name = "jellyfin_admin"; + }; + }; }; adminPassword = lib.mkOption { @@ -313,9 +321,9 @@ in uid=admin,ou=people,${cfg.ldap.dcdomain} %LDAP_PASSWORD% ou=people,${cfg.ldap.dcdomain} - (memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}) + (memberof=cn=${cfg.ldap.userGroup.result.name},ou=groups,${cfg.ldap.dcdomain}) ou=people,${cfg.ldap.dcdomain} - (memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}) + (memberof=cn=${cfg.ldap.adminGroup.result.name},ou=groups,${cfg.ldap.dcdomain}) false uid, cn, mail, displayName diff --git a/modules/services/nextcloud-server.nix b/modules/services/nextcloud-server.nix index c689ecd..3c71bbc 100644 --- a/modules/services/nextcloud-server.nix +++ b/modules/services/nextcloud-server.nix @@ -414,11 +414,9 @@ in }; }; - userGroup = lib.mkOption { - type = lib.types.str; + type = contracts.ldapgroup.request; description = "Group users must belong to to be able to login to Nextcloud."; - default = "nextcloud_user"; }; configID = lib.mkOption { @@ -1010,21 +1008,21 @@ in ${occ} ldap:set-config "${cID}" 'ldapEmailAttribute' \ 'mail' ${occ} ldap:set-config "${cID}" 'ldapGroupFilter' \ - '(&(|(objectclass=groupOfUniqueNames))(|(cn=${cfg'.userGroup})))' + '(&(|(objectclass=groupOfUniqueNames))(|(cn=${cfg'.userGroup.result.name})))' ${occ} ldap:set-config "${cID}" 'ldapGroupFilterGroups' \ - '${cfg'.userGroup}' + '${cfg'.userGroup.result.name}' ${occ} ldap:set-config "${cID}" 'ldapGroupFilterObjectclass' \ 'groupOfUniqueNames' ${occ} ldap:set-config "${cID}" 'ldapGroupMemberAssocAttr' \ 'uniqueMember' ${occ} ldap:set-config "${cID}" 'ldapLoginFilter' \ - '(&(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))(|(uid=%uid)(|(mail=%uid)(objectclass=%uid))))' + '(&(&(objectclass=person)(memberOf=cn=${cfg'.userGroup.result.name},ou=groups,${cfg'.dcdomain}))(|(uid=%uid)(|(mail=%uid)(objectclass=%uid))))' ${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \ 'mail;objectclass' ${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \ 'displayname' ${occ} ldap:set-config "${cID}" 'ldapUserFilter' \ - '(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))' + '(&(objectclass=person)(memberOf=cn=${cfg'.userGroup.result.name},ou=groups,${cfg'.dcdomain}))' ${occ} ldap:set-config "${cID}" 'ldapUserFilterMode' \ '1' ${occ} ldap:set-config "${cID}" 'ldapUserFilterObjectclass' \