diff --git a/sp-modules/auth/config-paths-needed.json b/sp-modules/auth/config-paths-needed.json index 47db4d4..7b922da 100644 --- a/sp-modules/auth/config-paths-needed.json +++ b/sp-modules/auth/config-paths-needed.json @@ -1,5 +1,7 @@ [ ["mailserver", "fqdn"], + ["mailserver", "ldap"], + ["mailserver", "vmailUID"], ["security", "acme", "certs"], ["selfprivacy", "domain"], ["selfprivacy", "modules"], diff --git a/sp-modules/auth/flake.nix b/sp-modules/auth/flake.nix index 46b2d32..15cfc9c 100644 --- a/sp-modules/auth/flake.nix +++ b/sp-modules/auth/flake.nix @@ -2,8 +2,9 @@ description = "User authentication and authorization module"; # TODO remove when Kanidm provisioning without groups assertion lands in NixOS - inputs.nixos-unstable.url = github:alexoundos/nixpkgs/679fd3fd318ce2d57d0cabfbd7f4b8857d78ae95; - # inputs.nixos-unstable.url = git+file:/data/nixpkgs?ref=kanidm-1.4.0&rev=3feae1d8a2681b57c07d3a212a083988da6b96d2; + # inputs.nixos-unstable.url = github:alexoundos/nixpkgs/679fd3fd318ce2d57d0cabfbd7f4b8857d78ae95; + # inputs.nixos-unstable.url = git+file:/data/nixpkgs?ref=kanidm-1.4.0&rev=1bac99358baea6a3268027b4e585c68cd4ef107d; + inputs.nixos-unstable.url = github:nixos/nixpkgs/7ffd9ae656aec493492b44d0ddfb28e79a1ea25d; outputs = { self, nixos-unstable }: { overlays.default = _final: prev: { @@ -11,13 +12,13 @@ kanidm oauth2-proxy; kanidm-provision = nixos-unstable.legacyPackages.${prev.system}.kanidm-provision.overrideAttrs (_: { - # version = "git"; - # src = prev.fetchFromGitHub { - # owner = "oddlama"; - # repo = "kanidm-provision"; - # rev = "d1f55c9247a6b25d30bbe90a74307aaac6306db4"; - # hash = "sha256-cZ3QbowmWX7j1eJRiUP52ao28xZzC96OdZukdWDHfFI="; - # }; + version = "git"; + src = prev.fetchFromGitHub { + owner = "oddlama"; + repo = "kanidm-provision"; + rev = "d1f55c9247a6b25d30bbe90a74307aaac6306db4"; + hash = "sha256-cZ3QbowmWX7j1eJRiUP52ao28xZzC96OdZukdWDHfFI="; + }; }); }; @@ -28,15 +29,18 @@ "services/security/oauth2-proxy-nginx.nix" ]; imports = [ - (nixos-unstable.legacyPackages.x86_64-linux.path - + /nixos/modules/services/security/kanidm.nix) + ./kanidm.nix (nixos-unstable.legacyPackages.x86_64-linux.path + /nixos/modules/services/security/oauth2-proxy.nix) (nixos-unstable.legacyPackages.x86_64-linux.path + /nixos/modules/services/security/oauth2-proxy-nginx.nix) ./module.nix + ./ldap-postfix.nix ]; nixpkgs.overlays = [ self.overlays.default ]; + + selfprivacy.modules.auth.enable = true; + selfprivacy.modules.auth.debug = true; }; configPathsNeeded = diff --git a/sp-modules/auth/kanidm.nix b/sp-modules/auth/kanidm.nix new file mode 100644 index 0000000..6e33eb5 --- /dev/null +++ b/sp-modules/auth/kanidm.nix @@ -0,0 +1,1008 @@ +{ + config, + lib, + options, + pkgs, + ... +}: +let + inherit (lib) + any + attrNames + attrValues + concatLines + concatLists + converge + filter + filterAttrs + filterAttrsRecursive + flip + foldl' + getExe + hasInfix + hasPrefix + isStorePath + last + mapAttrsToList + mkEnableOption + mkForce + mkIf + mkMerge + mkOption + mkPackageOption + optional + optionalString + splitString + subtractLists + types + unique + ; + + cfg = config.services.kanidm; + settingsFormat = pkgs.formats.toml { }; + # Remove null values, so we can document optional values that don't end up in the generated TOML file. + filterConfig = converge (a: filterAttrsRecursive (_: v: v != null) (builtins.removeAttrs a [ "provision" ])); + serverConfigFile = settingsFormat.generate "server.toml" (filterConfig cfg.serverSettings); + clientConfigFile = settingsFormat.generate "kanidm-config.toml" (filterConfig cfg.clientSettings); + unixConfigFile = settingsFormat.generate "kanidm-unixd.toml" (filterConfig cfg.unixSettings); + certPaths = builtins.map builtins.dirOf [ + cfg.serverSettings.tls_chain + cfg.serverSettings.tls_key + ]; + + # Merge bind mount paths and remove paths where a prefix is already mounted. + # This makes sure that if e.g. the tls_chain is in the nix store and /nix/store is already in the mount + # paths, no new bind mount is added. Adding subpaths caused problems on ofborg. + hasPrefixInList = + list: newPath: any (path: hasPrefix (builtins.toString path) (builtins.toString newPath)) list; + mergePaths = foldl' ( + merged: newPath: + let + # If the new path is a prefix to some existing path, we need to filter it out + filteredPaths = filter (p: !hasPrefix (builtins.toString newPath) (builtins.toString p)) merged; + # If a prefix of the new path is already in the list, do not add it + filteredNew = optional (!hasPrefixInList filteredPaths newPath) newPath; + in + filteredPaths ++ filteredNew + ) [ ]; + + defaultServiceConfig = { + # Setting the type to notify enables additional healthchecks, ensuring units + # after and requiring kanidm-* wait for it to complete startup + Type = "notify"; + BindReadOnlyPaths = [ + "/nix/store" + # For healthcheck notifications + "/run/systemd/notify" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + ]; + CapabilityBoundingSet = [ ]; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + # Implies ProtectSystem=strict, which re-mounts all paths + # DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectHome = true; + ProtectHostname = true; + # Would re-mount paths ignored by temporary root + #ProtectSystem = "strict"; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged @resources @setuid @keyring" + ]; + # Does not work well with the temporary root + #UMask = "0066"; + }; + + mkPresentOption = + what: + mkOption { + description = "Whether to ensure that this ${what} is present or absent."; + type = types.bool; + default = true; + }; + + filterPresent = filterAttrs (_: v: v.present); + + isGroupEmpty = g: ! g ? members || g ? members && g.members == [ ]; + + provisionStateJson = pkgs.writeText "provision-state.json" ( + builtins.toJSON { + inherit (cfg.provision) persons systems; + groups = + lib.attrsets.filterAttrs (_n: v: ! isGroupEmpty v) cfg.provision.groups; + } + ); + + # Only recover the admin account if a password should explicitly be provisioned + # for the account. Otherwise it is not needed for provisioning. + maybeRecoverAdmin = optionalString (cfg.provision.adminPasswordFile != null) '' + KANIDM_ADMIN_PASSWORD=$(< ${cfg.provision.adminPasswordFile}) + # We always reset the admin account password if a desired password was specified. + if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} admin --from-environment >/dev/null; then + echo "Failed to recover admin account" >&2 + exit 1 + fi + ''; + + # Recover the idm_admin account. If a password should explicitly be provisioned + # for the account we set it, otherwise we generate a new one because it is required + # for provisioning. + recoverIdmAdmin = + if cfg.provision.idmAdminPasswordFile != null then + '' + KANIDM_IDM_ADMIN_PASSWORD=$(< ${cfg.provision.idmAdminPasswordFile}) + # We always reset the idm_admin account password if a desired password was specified. + if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_IDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin --from-environment >/dev/null; then + echo "Failed to recover idm_admin account" >&2 + exit 1 + fi + '' + else + '' + # Recover idm_admin account + if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin -o json); then + echo "$recover_out" >&2 + echo "kanidm provision: Failed to recover admin account" >&2 + exit 1 + fi + if ! KANIDM_IDM_ADMIN_PASSWORD=$(grep '{"password' <<< "$recover_out" | ${getExe pkgs.jq} -r .password); then + echo "$recover_out" >&2 + echo "kanidm provision: Failed to parse password for idm_admin account" >&2 + exit 1 + fi + ''; + + emptyGroupsNames = + builtins.attrNames + (lib.attrsets.filterAttrs (_n: isGroupEmpty) cfg.provision.groups); + + createEmptyGroups = '' + readonly CLIENT_HOME=$RUNTIME_DIRECTORY/client_home + mkdir -p $CLIENT_HOME + HOME=$CLIENT_HOME KANIDM_PASSWORD="$KANIDM_IDM_ADMIN_PASSWORD" ${cfg.package}/bin/kanidm login --name idm_admin --url "${cfg.provision.instanceUrl}" --skip-hostname-verification + for group_name in ${lib.strings.concatLines emptyGroupsNames} + do + HOME=$CLIENT_HOME ${cfg.package}/bin/kanidm group create "$group_name" --name idm_admin --url "${cfg.provision.instanceUrl}" --skip-hostname-verification + done + # rm -r $CLIENT_HOME + ''; + + postStartScript = pkgs.writeShellScript "post-start" '' + set -euo pipefail + + # Wait for the kanidm server to come online + count=0 + while ! ${getExe pkgs.curl} -L --silent --max-time 1 --connect-timeout 1 --fail \ + ${optionalString cfg.provision.acceptInvalidCerts "--insecure"} \ + ${cfg.provision.instanceUrl} >/dev/null + do + sleep 1 + if [[ "$count" -eq 30 ]]; then + echo "Tried for at least 30 seconds, giving up..." + exit 1 + fi + count=$((count++)) + done + + ${recoverIdmAdmin} + ${maybeRecoverAdmin} + ${createEmptyGroups} + + KANIDM_PROVISION_IDM_ADMIN_TOKEN=$KANIDM_IDM_ADMIN_PASSWORD \ + ${getExe pkgs.kanidm-provision} \ + ${optionalString (!cfg.provision.autoRemove) "--no-auto-remove"} \ + ${optionalString cfg.provision.acceptInvalidCerts "--accept-invalid-certs"} \ + --url "${cfg.provision.instanceUrl}" \ + --state ${provisionStateJson} + ''; + + serverPort = + # ipv6: + if hasInfix "]:" cfg.serverSettings.bindaddress then + last (splitString "]:" cfg.serverSettings.bindaddress) + else + # ipv4: + if hasInfix "." cfg.serverSettings.bindaddress then + last (splitString ":" cfg.serverSettings.bindaddress) + # default is 8443 + else + "8443"; +in +{ + options.services.kanidm = { + enableClient = mkEnableOption "the Kanidm client"; + enableServer = mkEnableOption "the Kanidm server"; + enablePam = mkEnableOption "the Kanidm PAM and NSS integration"; + + package = mkPackageOption pkgs "kanidm" { }; + + serverSettings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + + options = { + bindaddress = mkOption { + description = "Address/port combination the webserver binds to."; + example = "[::1]:8443"; + type = types.str; + }; + # Should be optional but toml does not accept null + ldapbindaddress = mkOption { + description = '' + Address and port the LDAP server is bound to. Setting this to `null` disables the LDAP interface. + ''; + example = "[::1]:636"; + default = null; + type = types.nullOr types.str; + }; + origin = mkOption { + description = "The origin of your Kanidm instance. Must have https as protocol."; + example = "https://idm.example.org"; + type = types.strMatching "^https://.*"; + }; + domain = mkOption { + description = '' + The `domain` that Kanidm manages. Must be below or equal to the domain + specified in `serverSettings.origin`. + This can be left at `null`, only if your instance has the role `ReadOnlyReplica`. + While it is possible to change the domain later on, it requires extra steps! + Please consider the warnings and execute the steps described + [in the documentation](https://kanidm.github.io/kanidm/stable/administrivia.html#rename-the-domain). + ''; + example = "example.org"; + default = null; + type = types.nullOr types.str; + }; + db_path = mkOption { + description = "Path to Kanidm database."; + default = "/var/lib/kanidm/kanidm.db"; + readOnly = true; + type = types.path; + }; + tls_chain = mkOption { + description = "TLS chain in pem format."; + type = types.path; + }; + tls_key = mkOption { + description = "TLS key in pem format."; + type = types.path; + }; + log_level = mkOption { + description = "Log level of the server."; + default = "info"; + type = types.enum [ + "info" + "debug" + "trace" + ]; + }; + role = mkOption { + description = "The role of this server. This affects the replication relationship and thereby available features."; + default = "WriteReplica"; + type = types.enum [ + "WriteReplica" + "WriteReplicaNoUI" + "ReadOnlyReplica" + ]; + }; + online_backup = { + path = mkOption { + description = "Path to the output directory for backups."; + type = types.path; + default = "/var/lib/kanidm/backups"; + }; + schedule = mkOption { + description = "The schedule for backups in cron format."; + type = types.str; + default = "00 22 * * *"; + }; + versions = mkOption { + description = '' + Number of backups to keep. + + The default is set to `0`, in order to disable backups by default. + ''; + type = types.ints.unsigned; + default = 0; + example = 7; + }; + }; + }; + }; + default = { }; + description = '' + Settings for Kanidm, see + [the documentation](https://kanidm.github.io/kanidm/stable/server_configuration.html) + and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/server.toml) + for possible values. + ''; + }; + + clientSettings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + + options.uri = mkOption { + description = "Address of the Kanidm server."; + example = "http://127.0.0.1:8080"; + type = types.str; + }; + }; + description = '' + Configure Kanidm clients, needed for the PAM daemon. See + [the documentation](https://kanidm.github.io/kanidm/stable/client_tools.html#kanidm-configuration) + and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/config) + for possible values. + ''; + }; + + unixSettings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + + options = { + pam_allowed_login_groups = mkOption { + description = "Kanidm groups that are allowed to login using PAM."; + example = "my_pam_group"; + type = types.listOf types.str; + }; + hsm_pin_path = mkOption { + description = "Path to a HSM pin."; + default = "/var/cache/kanidm-unixd/hsm-pin"; + type = types.path; + }; + }; + }; + description = '' + Configure Kanidm unix daemon. + See [the documentation](https://kanidm.github.io/kanidm/stable/integrations/pam_and_nsswitch.html#the-unix-daemon) + and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/unixd) + for possible values. + ''; + }; + + provision = { + enable = mkEnableOption "provisioning of groups, users and oauth2 resource servers"; + + instanceUrl = mkOption { + description = "The instance url to which the provisioning tool should connect."; + default = "https://localhost:${serverPort}"; + defaultText = ''"https://localhost:"''; + type = types.str; + }; + + acceptInvalidCerts = mkOption { + description = '' + Whether to allow invalid certificates when provisioning the target instance. + By default this is only allowed when the instanceUrl is localhost. This is + dangerous when used with an external URL. + ''; + type = types.bool; + default = hasPrefix "https://localhost:" cfg.provision.instanceUrl; + defaultText = ''hasPrefix "https://localhost:" cfg.provision.instanceUrl''; + }; + + adminPasswordFile = mkOption { + description = "Path to a file containing the admin password for kanidm. Do NOT use a file from the nix store here!"; + example = "/run/secrets/kanidm-admin-password"; + default = null; + type = types.nullOr types.path; + }; + + idmAdminPasswordFile = mkOption { + description = '' + Path to a file containing the idm admin password for kanidm. Do NOT use a file from the nix store here! + If this is not given but provisioning is enabled, the idm_admin password will be reset on each restart. + ''; + example = "/run/secrets/kanidm-idm-admin-password"; + default = null; + type = types.nullOr types.path; + }; + + autoRemove = mkOption { + description = '' + Determines whether deleting an entity in this provisioning config should automatically + cause them to be removed from kanidm, too. This works because the provisioning tool tracks + all entities it has ever created. If this is set to false, you need to explicitly specify + `present = false` to delete an entity. + ''; + type = types.bool; + default = true; + }; + + groups = mkOption { + description = "Provisioning of kanidm groups"; + default = { }; + type = types.attrsOf ( + types.submodule (groupSubmod: { + options = { + present = mkPresentOption "group"; + + members = mkOption { + description = "List of kanidm entities (persons, groups, ...) which are part of this group."; + type = types.listOf types.str; + apply = unique; + default = [ ]; + }; + }; + config.members = concatLists ( + flip mapAttrsToList cfg.provision.persons ( + person: personCfg: + optional ( + personCfg.present && builtins.elem groupSubmod.config._module.args.name personCfg.groups + ) person + ) + ); + }) + ); + }; + + persons = mkOption { + description = "Provisioning of kanidm persons"; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + present = mkPresentOption "person"; + + displayName = mkOption { + description = "Display name"; + type = types.str; + example = "My User"; + }; + + legalName = mkOption { + description = "Full legal name"; + type = types.nullOr types.str; + example = "Jane Doe"; + default = null; + }; + + mailAddresses = mkOption { + description = "Mail addresses. First given address is considered the primary address."; + type = types.listOf types.str; + example = [ "jane.doe@example.com" ]; + default = [ ]; + }; + + groups = mkOption { + description = "List of groups this person should belong to."; + type = types.listOf types.str; + apply = unique; + default = [ ]; + }; + }; + } + ); + }; + + systems.oauth2 = mkOption { + description = "Provisioning of oauth2 resource servers"; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + present = mkPresentOption "oauth2 resource server"; + + public = mkOption { + description = "Whether this is a public client (enforces PKCE, doesn't use a basic secret)"; + type = types.bool; + default = false; + }; + + displayName = mkOption { + description = "Display name"; + type = types.str; + example = "Some Service"; + }; + + originUrl = mkOption { + description = "The origin URL of the service. OAuth2 redirects will only be allowed to sites under this origin."; + type = + let + originStrType = types.strMatching ".*://.*$"; + in + types.either originStrType (types.nonEmptyListOf originStrType); + example = "https://someservice.example.com/"; + }; + + originLanding = mkOption { + description = "When redirecting from the Kanidm Apps Listing page, some linked applications may need to land on a specific page to trigger oauth2/oidc interactions."; + type = types.str; + example = "https://someservice.example.com/home"; + }; + + basicSecretFile = mkOption { + description = '' + The basic secret to use for this service. If null, the random secret generated + by kanidm will not be touched. Do NOT use a path from the nix store here! + ''; + type = types.nullOr types.path; + example = "/run/secrets/some-oauth2-basic-secret"; + default = null; + }; + + enableLocalhostRedirects = mkOption { + description = "Allow localhost redirects. Only for public clients."; + type = types.bool; + default = false; + }; + + enableLegacyCrypto = mkOption { + description = "Enable legacy crypto on this client. Allows JWT signing algorthms like RS256."; + type = types.bool; + default = false; + }; + + allowInsecureClientDisablePkce = mkOption { + description = '' + Disable PKCE on this oauth2 resource server to work around insecure clients + that may not support it. You should request the client to enable PKCE! + Only for non-public clients. + ''; + type = types.bool; + default = false; + }; + + preferShortUsername = mkOption { + description = "Use 'name' instead of 'spn' in the preferred_username claim"; + type = types.bool; + default = false; + }; + + scopeMaps = mkOption { + description = '' + Maps kanidm groups to returned oauth scopes. + See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information. + ''; + type = types.attrsOf (types.listOf types.str); + default = { }; + }; + + supplementaryScopeMaps = mkOption { + description = '' + Maps kanidm groups to additionally returned oauth scopes. + See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information. + ''; + type = types.attrsOf (types.listOf types.str); + default = { }; + }; + + removeOrphanedClaimMaps = mkOption { + description = "Whether claim maps not specified here but present in kanidm should be removed from kanidm."; + type = types.bool; + default = true; + }; + + claimMaps = mkOption { + description = '' + Adds additional claims (and values) based on which kanidm groups an authenticating party belongs to. + See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information. + ''; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + joinType = mkOption { + description = '' + Determines how multiple values are joined to create the claim value. + See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information. + ''; + type = types.enum [ + "array" + "csv" + "ssv" + ]; + default = "array"; + }; + + valuesByGroup = mkOption { + description = "Maps kanidm groups to values for the claim."; + default = { }; + type = types.attrsOf (types.listOf types.str); + }; + }; + } + ); + }; + }; + } + ); + }; + }; + }; + + config = mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) { + assertions = + let + entityList = + type: attrs: flip mapAttrsToList (filterPresent attrs) (name: _: { inherit type name; }); + entities = + entityList "group" cfg.provision.groups + ++ entityList "person" cfg.provision.persons + ++ entityList "oauth2" cfg.provision.systems.oauth2; + + # Accumulate entities by name. Track corresponding entity types for later duplicate check. + entitiesByName = foldl' ( + acc: { type, name }: acc // { ${name} = (acc.${name} or [ ]) ++ [ type ]; } + ) { } entities; + + assertGroupsKnown = + opt: groups: + let + knownGroups = attrNames (filterPresent cfg.provision.groups); + unknownGroups = subtractLists knownGroups groups; + in + { + assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == [ ]; + message = "${opt} refers to unknown groups: ${toString unknownGroups}"; + }; + + assertEntitiesKnown = + opt: entities: + let + unknownEntities = subtractLists (attrNames entitiesByName) entities; + in + { + assertion = (cfg.enableServer && cfg.provision.enable) -> unknownEntities == [ ]; + message = "${opt} refers to unknown entities: ${toString unknownEntities}"; + }; + in + [ + { + assertion = + !cfg.enableServer + || ((cfg.serverSettings.tls_chain or null) == null) + || (!isStorePath cfg.serverSettings.tls_chain); + message = '' + points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { + assertion = + !cfg.enableServer + || ((cfg.serverSettings.tls_key or null) == null) + || (!isStorePath cfg.serverSettings.tls_key); + message = '' + points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { + assertion = !cfg.enableClient || options.services.kanidm.clientSettings.isDefined; + message = '' + needs to be configured + if the client is enabled. + ''; + } + { + assertion = !cfg.enablePam || options.services.kanidm.clientSettings.isDefined; + message = '' + needs to be configured + for the PAM daemon to connect to the Kanidm server. + ''; + } + { + assertion = + !cfg.enableServer + || ( + cfg.serverSettings.domain == null + -> cfg.serverSettings.role == "WriteReplica" || cfg.serverSettings.role == "WriteReplicaNoUI" + ); + message = '' + can only be set if this instance + is not a ReadOnlyReplica. Otherwise the db would inherit it from + the instance it follows. + ''; + } + { + assertion = cfg.provision.enable -> cfg.enableServer; + message = " requires to be true"; + } + # If any secret is provisioned, the kanidm package must have some required patches applied to it + { + assertion = + ( + cfg.provision.enable + && ( + cfg.provision.adminPasswordFile != null + || cfg.provision.idmAdminPasswordFile != null + || any (x: x.basicSecretFile != null) (attrValues (filterPresent cfg.provision.systems.oauth2)) + ) + ) + -> cfg.package.enableSecretProvisioning; + message = '' + Specifying an admin account password or oauth2 basicSecretFile requires kanidm to be built with the secret provisioning patches. + You may want to set `services.kanidm.package = pkgs.kanidm.withSecretProvisioning;`. + ''; + } + # Entity names must be globally unique: + ( + let + # Filter all names that occurred in more than one entity type. + duplicateNames = filterAttrs (_: v: builtins.length v > 1) entitiesByName; + in + { + assertion = cfg.provision.enable -> duplicateNames == { }; + message = '' + services.kanidm.provision requires all entity names (group, person, oauth2, ...) to be unique! + ${concatLines ( + mapAttrsToList (name: xs: " - '${name}' used as: ${toString xs}") duplicateNames + )}''; + } + ) + ] + ++ flip mapAttrsToList (filterPresent cfg.provision.persons) ( + person: personCfg: + assertGroupsKnown "services.kanidm.provision.persons.${person}.groups" personCfg.groups + ) + ++ flip mapAttrsToList (filterPresent cfg.provision.groups) ( + group: groupCfg: + assertEntitiesKnown "services.kanidm.provision.groups.${group}.members" groupCfg.members + ) + ++ concatLists ( + flip mapAttrsToList (filterPresent cfg.provision.systems.oauth2) ( + oauth2: oauth2Cfg: + [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.scopeMaps" ( + attrNames oauth2Cfg.scopeMaps + )) + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.supplementaryScopeMaps" ( + attrNames oauth2Cfg.supplementaryScopeMaps + )) + ] + ++ concatLists ( + flip mapAttrsToList oauth2Cfg.claimMaps ( + claim: claimCfg: [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim}.valuesByGroup" ( + attrNames claimCfg.valuesByGroup + )) + # At least one group must map to a value in each claim map + { + assertion = + (cfg.provision.enable && cfg.enableServer) + -> any (xs: xs != [ ]) (attrValues claimCfg.valuesByGroup); + message = "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim} does not specify any values for any group"; + } + # Public clients cannot define a basic secret + { + assertion = + (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> oauth2Cfg.basicSecretFile == null; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot specify a basic secret"; + } + # Public clients cannot disable PKCE + { + assertion = + (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) + -> !oauth2Cfg.allowInsecureClientDisablePkce; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot disable PKCE"; + } + # Non-public clients cannot enable localhost redirects + { + assertion = + (cfg.provision.enable && cfg.enableServer && !oauth2Cfg.public) + -> !oauth2Cfg.enableLocalhostRedirects; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a non-public client and thus cannot enable localhost redirects"; + } + ] + ) + ) + ) + ); + + environment.systemPackages = mkIf cfg.enableClient [ cfg.package ]; + + systemd.tmpfiles.settings."10-kanidm" = { + ${cfg.serverSettings.online_backup.path}.d = { + mode = "0700"; + user = "kanidm"; + group = "kanidm"; + }; + }; + + systemd.services.kanidm = mkIf cfg.enableServer { + description = "kanidm identity management daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig = mkMerge [ + # Merge paths and ignore existing prefixes needs to sidestep mkMerge + ( + defaultServiceConfig + // { + BindReadOnlyPaths = mergePaths (defaultServiceConfig.BindReadOnlyPaths ++ certPaths); + } + ) + { + StateDirectory = "kanidm"; + StateDirectoryMode = "0700"; + RuntimeDirectory = "kanidmd"; + ExecStart = "${cfg.package}/bin/kanidmd server -c ${serverConfigFile}"; + ExecStartPost = mkIf cfg.provision.enable postStartScript; + User = "kanidm"; + Group = "kanidm"; + + BindPaths = [ + # To create the socket + "/run/kanidmd:/run/kanidmd" + # To store backups + cfg.serverSettings.online_backup.path + ]; + + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + # This would otherwise override the CAP_NET_BIND_SERVICE capability. + PrivateUsers = mkForce false; + # Port needs to be exposed to the host network + PrivateNetwork = mkForce false; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + #TemporaryFileSystem = "/:ro"; + } + ]; + environment.RUST_LOG = "info"; + }; + + systemd.services.kanidm-unixd = mkIf cfg.enablePam { + description = "Kanidm PAM daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + restartTriggers = [ + unixConfigFile + clientConfigFile + ]; + serviceConfig = mkMerge [ + defaultServiceConfig + { + CacheDirectory = "kanidm-unixd"; + CacheDirectoryMode = "0700"; + RuntimeDirectory = "kanidm-unixd"; + ExecStart = "${cfg.package}/bin/kanidm_unixd"; + User = "kanidm-unixd"; + Group = "kanidm-unixd"; + + BindReadOnlyPaths = [ + "-/etc/kanidm" + "-/etc/static/kanidm" + "-/etc/ssl" + "-/etc/static/ssl" + "-/etc/passwd" + "-/etc/group" + ]; + BindPaths = [ + # To create the socket + "/run/kanidm-unixd:/var/run/kanidm-unixd" + ]; + # Needs to connect to kanidmd + PrivateNetwork = mkForce false; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + TemporaryFileSystem = "/:ro"; + } + ]; + environment.RUST_LOG = "info"; + }; + + systemd.services.kanidm-unixd-tasks = mkIf cfg.enablePam { + description = "Kanidm PAM home management daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "kanidm-unixd.service" + ]; + partOf = [ "kanidm-unixd.service" ]; + restartTriggers = [ + unixConfigFile + clientConfigFile + ]; + serviceConfig = { + ExecStart = "${cfg.package}/bin/kanidm_unixd_tasks"; + + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + "-/etc/kanidm" + "-/etc/static/kanidm" + ]; + BindPaths = [ + # To manage home directories + "/home" + # To connect to kanidm-unixd + "/run/kanidm-unixd:/var/run/kanidm-unixd" + ]; + # CAP_DAC_OVERRIDE is needed to ignore ownership of unixd socket + CapabilityBoundingSet = [ + "CAP_CHOWN" + "CAP_FOWNER" + "CAP_DAC_OVERRIDE" + "CAP_DAC_READ_SEARCH" + ]; + IPAddressDeny = "any"; + # Need access to users + PrivateUsers = false; + # Need access to home directories + ProtectHome = false; + RestrictAddressFamilies = [ "AF_UNIX" ]; + TemporaryFileSystem = "/:ro"; + Restart = "on-failure"; + }; + environment.RUST_LOG = "info"; + }; + + # These paths are hardcoded + environment.etc = mkMerge [ + (mkIf cfg.enableServer { "kanidm/server.toml".source = serverConfigFile; }) + (mkIf options.services.kanidm.clientSettings.isDefined { + "kanidm/config".source = clientConfigFile; + }) + (mkIf cfg.enablePam { "kanidm/unixd".source = unixConfigFile; }) + ]; + + system.nssModules = mkIf cfg.enablePam [ cfg.package ]; + + system.nssDatabases.group = optional cfg.enablePam "kanidm"; + system.nssDatabases.passwd = optional cfg.enablePam "kanidm"; + + users.groups = mkMerge [ + (mkIf cfg.enableServer { kanidm = { }; }) + (mkIf cfg.enablePam { kanidm-unixd = { }; }) + ]; + users.users = mkMerge [ + (mkIf cfg.enableServer { + kanidm = { + description = "Kanidm server"; + isSystemUser = true; + group = "kanidm"; + packages = [ cfg.package ]; + }; + }) + (mkIf cfg.enablePam { + kanidm-unixd = { + description = "Kanidm PAM daemon"; + isSystemUser = true; + group = "kanidm-unixd"; + }; + }) + ]; + }; + + meta.maintainers = with lib.maintainers; [ + Flakebi + oddlama + ]; + meta.buildDocsInSandbox = false; +} diff --git a/sp-modules/auth/ldap-postfix.nix b/sp-modules/auth/ldap-postfix.nix new file mode 100644 index 0000000..3a0e905 --- /dev/null +++ b/sp-modules/auth/ldap-postfix.nix @@ -0,0 +1,81 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.mailserver; + + appendLdapBindPwd = + { name, file, prefix, suffix ? "", passwordFile, destination }: + pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + baseDir=$(dirname ${destination}) + if (! test -d "$baseDir"); then + mkdir -p $baseDir + chmod 755 $baseDir + fi + + cat ${file} > ${destination} + echo -n '${prefix}' >> ${destination} + cat ${passwordFile} >> ${destination} + echo -n '${suffix}' >> ${destination} + chmod 600 ${destination} + ''; + + ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf"; + submissionOptions.smtpd_sender_login_maps = + lib.mkForce "hash:/etc/postfix/vaccounts,ldap:${ldapSenderLoginMapFile}"; + commonLdapConfig = '' + server_host = ${lib.concatStringsSep " " cfg.ldap.uris} + start_tls = ${if cfg.ldap.startTls then "yes" else "no"} + version = 3 + # tls_ca_cert_file = ${cfg.ldap.tlsCAFile} + # tls_require_cert = yes + + search_base = ${cfg.ldap.searchBase} + scope = ${cfg.ldap.searchScope} + + bind = yes + bind_dn = ${cfg.ldap.bind.dn} + ''; + ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.mailAttribute} + ''; + appendPwdInSenderLoginMap = appendLdapBindPwd { + name = "ldap-sender-login-map"; + file = ldapSenderLoginMap; + prefix = "bind_pw = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapSenderLoginMapFile; + }; + + ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.uidAttribute} + ''; + ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf"; + appendPwdInVirtualMailboxMap = appendLdapBindPwd { + name = "ldap-virtual-mailbox-map"; + file = ldapVirtualMailboxMap; + prefix = "bind_pw = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapVirtualMailboxMapFile; + }; +in +{ + systemd.services.postfix-setup = { + preStart = '' + ${appendPwdInVirtualMailboxMap} + ${appendPwdInSenderLoginMap} + ''; + restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ]; + }; + services.postfix = { + # the list should be merged with other options from nixos-mailserver + config.virtual_mailbox_maps = [ "ldap:${ldapVirtualMailboxMapFile}" ]; + submissionOptions = submissionOptions; + submissionsOptions = submissionOptions; + }; +} diff --git a/sp-modules/auth/module.nix b/sp-modules/auth/module.nix index d49fcce..d51335f 100644 --- a/sp-modules/auth/module.nix +++ b/sp-modules/auth/module.nix @@ -10,6 +10,12 @@ let kanidm-bind-address = "127.0.0.1:3013"; ldap_host = "127.0.0.1"; ldap_port = 3636; + # e.g. "dc=mydomain,dc=com" + ldap_base_dn = + lib.strings.concatMapStringsSep + "," + (x: "dc=" + x) + (lib.strings.splitString "." domain); dovecot-oauth2-conf-file = pkgs.writeTextFile { name = "dovecot-oauth2.conf.ext"; @@ -25,23 +31,81 @@ let active_attribute = active active_value = true openid_configuration_url = ${oauth2-discovery-url "roundcube"} - debug = yes # FIXME + debug = ${if cfg.debug then "yes" else "no"} ''; }; - provisionAdminPassword = "abcd1234"; - provisionIdmAdminPassword = "abcd1234"; # FIXME + lua_core_path = "${pkgs.luajitPackages.lua-resty-core}/lib/lua/5.1/?.lua"; + lua_lrucache_path = "${pkgs.luajitPackages.lua-resty-lrucache}/lib/lua/5.1/?.lua"; + lua_path = "${lua_core_path};${lua_lrucache_path};"; + ldapConfFile = "/run/dovecot2/dovecot-ldap.conf.ext"; # FIXME get "dovecot2" from `config` + mkLdapSearchScope = scope: ( + if scope == "sub" then "subtree" + else if scope == "one" then "onelevel" + else scope + ); + appendLdapBindPwd = + { name, file, prefix, suffix ? "", passwordFile, destination }: + pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + baseDir=$(dirname ${destination}) + if (! test -d "$baseDir"); then + mkdir -p $baseDir + chmod 755 $baseDir + fi + + cat ${file} > ${destination} + echo -n '${prefix}' >> ${destination} + cat ${passwordFile} >> ${destination} + echo -n '${suffix}' >> ${destination} + chmod 600 ${destination} + ''; + dovecot-ldap-config = pkgs.writeTextFile { + name = "dovecot-ldap.conf.ext.template"; + text = '' + ldap_version = 3 + uris = ${lib.concatStringsSep " " config.mailserver.ldap.uris} + ${lib.optionalString config.mailserver.ldap.startTls '' + tls = yes + ''} + # tls_require_cert = hard + # tls_ca_cert_file = ${config.mailserver.ldap.tlsCAFile} + dn = ${config.mailserver.ldap.bind.dn} + sasl_bind = no + auth_bind = no + base = ${config.mailserver.ldap.searchBase} + scope = ${mkLdapSearchScope config.mailserver.ldap.searchScope} + ${lib.optionalString (config.mailserver.ldap.dovecot.userAttrs != null) '' + user_attrs = ${config.mailserver.ldap.dovecot.userAttrs} + ''} + user_filter = ${config.mailserver.ldap.dovecot.userFilter} + ''; + }; + setPwdInLdapConfFile = appendLdapBindPwd { + name = "ldap-conf-file"; + file = dovecot-ldap-config; + prefix = ''dnpass = "''; + suffix = ''"''; + passwordFile = config.mailserver.ldap.bind.passwordFile; + destination = ldapConfFile; + }; in { options.selfprivacy.modules.auth = { enable = lib.mkOption { - default = true; + default = false; type = lib.types.bool; }; subdomain = lib.mkOption { default = "auth"; type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; }; + debug = lib.mkOption { + default = false; + type = lib.types.bool; + }; }; config = lib.mkIf cfg.enable { @@ -54,7 +118,6 @@ in # kanidm with Rust code patches for OAuth and admin passwords provisioning package = pkgs.kanidm.withSecretProvisioning; - # FIXME # package = pkgs.kanidm.withSecretProvisioning.overrideAttrs (_: { # version = "git"; # src = pkgs.fetchFromGitHub { @@ -91,10 +154,6 @@ in provision = { enable = true; autoRemove = false; - - # FIXME read randomly generated password from ? - # adminPasswordFile = pkgs.writeText "admin-pw" provisionAdminPassword; - # idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword; }; enableClient = true; clientSettings = { @@ -103,22 +162,113 @@ in verify_hostnames = false; # FIXME }; }; + # systemd.services.kanidm.serviceConfig.ExecStartPost = lib.mkBefore '' + # # check kanidm online here with curl again? + # # use API key for group creation? + # ''; + # services.phpfpm.pools.roundcube.settings = { + # catch_workers_output = true; + # "php_admin_value[error_log]" = "stdout"; + # "php_admin_flag[log_errors]" = true; + # "php_admin_value[log_level]" = "debug"; + # }; + services.phpfpm.phpOptions = '' + error_reporting = E_ALL + display_errors = on; + ''; + systemd.services.phpfpm-roundcube.serviceConfig = { + StandardError = "journal"; + StandardOutput = "journal"; + }; services.nginx = { enable = true; + additionalModules = + lib.lists.optional cfg.debug pkgs.nginxModules.lua; + commonHttpConfig = lib.strings.optionalString cfg.debug '' + log_format kanidm escape=none '$request $status\n' + '[Request body]: $request_body\n' + '[Header]: $resp_header\n' + '[Response Body]: $resp_body\n\n'; + lua_package_path "${lua_path}"; + ''; virtualHosts.${auth-fqdn} = { useACMEHost = domain; forceSSL = true; locations."/" = { - proxyPass = - "https://${kanidm-bind-address}"; + # extraConfig = '' + # if ($args != $new_args) { + # rewrite ^ /ui/oauth2?$new_args? last; + # } + # ''; + extraConfig = lib.strings.optionalString cfg.debug '' + access_log /var/log/nginx/kanidm.log kanidm; + + lua_need_request_body on; + + # log header + set $req_header ""; + set $resp_header ""; + header_filter_by_lua ' + local h = ngx.req.get_headers() + for k, v in pairs(h) do + ngx.var.req_header = ngx.var.req_header .. k.."="..v.." " + end + local rh = ngx.resp.get_headers() + for k, v in pairs(rh) do + ngx.var.resp_header = ngx.var.resp_header .. k.."="..v.." " + end + '; + + # log body + set $resp_body ""; + body_filter_by_lua ' + local resp_body = string.sub(ngx.arg[1], 1, 4000) + ngx.ctx.buffered = (ngx.ctx.buffered or "") .. resp_body + if ngx.arg[2] then + ngx.var.resp_body = ngx.ctx.buffered + end + '; + ''; + proxyPass = "https://${kanidm-bind-address}"; }; }; + # appendHttpConfig = '' + # # Define a map to modify redirect_uri and append %2F if missing + # map $args $new_args { + # ~^((.*)(redirect_uri=[^&]+)(?!%2F)(.*))$ $2$3%2F$4; + # default $args; + # } + # ''; }; # TODO move to mailserver module everything below - mailserver.debug = true; # FIXME + mailserver.debug = cfg.debug; # FIXME mailserver.mailDirectory = "/var/vmail"; + + mailserver.loginAccounts = lib.mkForce { }; + mailserver.extraVirtualAliases = lib.mkForce { }; + # LDAP is needed for Postfix to query Kanidm about email address ownership + # LDAP is needed for Dovecot also. + mailserver.ldap = { + enable = false; + # bind.dn = "uid=mail,ou=persons," + ldap_base_dn; + bind.dn = "dn=token"; + # TODO change in this file should trigger system restart dovecot + bind.passwordFile = "/run/keys/dovecot/kanidm-service-account-token"; # FIXME + # searchBase = "ou=persons," + ldap_base_dn; + searchBase = ldap_base_dn; + # searchScope = "sub"; + uris = [ "ldaps://localhost:${toString ldap_port}" ]; + + # note: in `ldapsearch` first comes filter, then attributes + dovecot.userAttrs = "+"; # all operational attributes + # TODO: investigate whether "mail=%u" is better than: + # dovecot.userFilter = "(&(class=person)(uid=%n))"; + postfix.mailAttribute = "mail"; + postfix.uidAttribute = "uid"; + }; + services.dovecot2.extraConfig = '' auth_mechanisms = xoauth2 oauthbearer @@ -130,12 +280,12 @@ in userdb { driver = static - args = uid=virtualMail gid=virtualMail home=/var/vmail/%u + args = uid=virtualMail gid=virtualMail home=/var/vmail/${domain}/%u } # provide SASL via unix socket to postfix service auth { - unix_listener /var/lib/postfix/private/auth { + unix_listener /var/lib/postfix/private-auth { mode = 0660 user = postfix group = postfix @@ -154,7 +304,15 @@ in } } + userdb { + driver = ldap + args = ${ldapConfFile} + default_fields = home=/var/vmail/${domain}/%u uid=${toString config.mailserver.vmailUID} gid=${toString config.mailserver.vmailUID} + } + #auth_username_format = %Ln + + # FIXME auth_debug = yes auth_debug_passwords = yes # Be cautious with this in production as it logs passwords auth_verbose = yes @@ -162,11 +320,30 @@ in ''; services.dovecot2.enablePAM = false; services.postfix.extraConfig = '' - smtpd_sasl_local_domain = ${domain} - smtpd_relay_restrictions = permit_sasl_authenticated, reject - smtpd_sasl_type = dovecot - smtpd_sasl_path = private/auth - smtpd_sasl_auth_enable = yes + debug_peer_list = 94.43.135.210, 134.209.202.195 + debug_peer_level = 3 + smtp_use_tls = yes + # these below are already set in nixos-mailserver/mail-server/postfix.nix + # smtpd_sasl_local_domain = ${domain} + # smtpd_relay_restrictions = permit_sasl_authenticated, reject + # smtpd_sender_restrictions = + # smtpd_sender_login_maps = + # smtpd_sasl_type = dovecot + # smtpd_sasl_path = private-auth + # smtpd_sasl_auth_enable = yes ''; + + systemd.services.dovecot2 = { + # TODO does it merge with existing preStart? + preStart = setPwdInLdapConfFile + "\n"; + }; + + # does it merge with existing restartTriggers? + systemd.services.postfix.restartTriggers = [ setPwdInLdapConfFile ]; + + environment.systemPackages = lib.lists.optionals cfg.debug [ + pkgs.shelldap + pkgs.openldap + ]; }; } diff --git a/sp-modules/roundcube/module.nix b/sp-modules/roundcube/module.nix index 5d5ecc0..a2b60df 100644 --- a/sp-modules/roundcube/module.nix +++ b/sp-modules/roundcube/module.nix @@ -5,6 +5,79 @@ let auth-module = config.selfprivacy.modules.auth; auth-fqdn = auth-module.subdomain + "." + domain; oauth-client-id = "roundcube"; + dovecot-service-account-name = "dovecot-service-account"; + postfix-service-account-name = "postfix-service-account"; + dovecot-service-account-token-name = "dovecot-service-account-token"; + postfix-service-account-token-name = "postfix-service-account-token"; + # dovecot-service-account-token-fp = "/run/kanidm/token/dovecot"; + dovecot-service-account-token-fp = + "/run/keys/dovecot/kanidm-service-account-token"; + postfix-service-account-token-fp = + "/run/keys/postfix/kanidm-service-account-token"; + dovecot-group = "dovecot2"; # FIXME + postfix-group = "postfix"; # FIXME + # FIXME use usernames and groups from `config` + # FIXME dependency on dovecot2 and postfix + # set-group-ID bit allows for kanidm user to create files, + # which inherit directory group (.e.g dovecot, postfix) + kanidmExecStartPostScriptRoot = pkgs.writeShellScript + "roundcube-kanidm-ExecStartPost-root-script.sh" + '' + mkdir -p -v --mode=u+rwx,g+rs,g-w,o-rwx /run/keys/dovecot + chown kanidm:dovecot2 /run/keys/dovecot + + mkdir -p -v --mode=u+rwx,g+rs,g-w,o-rwx /run/keys/postfix + chown kanidm:postfix /run/keys/postfix + ''; + # FIXME parameterize names like "dovecot2" group + kanidmExecStartPostScript = pkgs.writeShellScript + "roundcube-kanidm-ExecStartPost-script.sh" + '' + export HOME=$RUNTIME_DIRECTORY/client_home + readonly KANIDM="${pkgs.kanidm}/bin/kanidm" + + # get Kanidm service account for Dovecot + KANIDM_SERVICE_ACCOUNT="$($KANIDM service-account list --name idm_admin | grep -E "^name: ${dovecot-service-account-name}$")" + echo KANIDM_SERVICE_ACCOUNT: "$KANIDM_SERVICE_ACCOUNT" + if [ -n "$KANIDM_SERVICE_ACCOUNT" ] + then + echo "kanidm service account \"${dovecot-service-account-name}\" is found" + else + echo "kanidm service account \"${dovecot-service-account-name}\" is not found" + echo "creating new kanidm service account \"${dovecot-service-account-name}\"" + if $KANIDM service-account create --name idm_admin ${dovecot-service-account-name} ${dovecot-service-account-name} idm_admin + then + "kanidm service account \"${dovecot-service-account-name}\" created" + else + echo "error: cannot create kanidm service account \"${dovecot-service-account-name}\"" + exit 1 + fi + fi + + # add Kanidm service account to `idm_mail_servers` group + $KANIDM group add-members idm_mail_servers ${dovecot-service-account-name} + + # create a new read-only token for Dovecot + if ! KANIDM_SERVICE_ACCOUNT_TOKEN_JSON="$($KANIDM service-account api-token generate --name idm_admin ${dovecot-service-account-name} ${dovecot-service-account-token-name} --output json)" + then + echo "error: kanidm CLI returns an error when trying to generate service-account api-token" + exit 1 + fi + if ! KANIDM_SERVICE_ACCOUNT_TOKEN="$(echo "$KANIDM_SERVICE_ACCOUNT_TOKEN_JSON" | ${lib.getExe pkgs.jq} -r .result)" + then + echo "error: cannot get service-account API token from JSON" + exit 1 + fi + + # if ! printf "%s\n" "$KANIDM_SERVICE_ACCOUNT_TOKEN" > ${dovecot-service-account-token-fp} + if ! install --mode=640 \ + <(printf "%s" "$KANIDM_SERVICE_ACCOUNT_TOKEN") \ + ${dovecot-service-account-token-fp} + then + echo "error: cannot write token to \"${dovecot-service-account-token-fp}\"" + exit 1 + fi + ''; in { options.selfprivacy.modules.roundcube = { @@ -32,15 +105,40 @@ in }; config = lib.mkIf cfg.enable { + # FIXME get user names from `config` + # in order to allow access below /run/keys + users.groups.keys.members = [ "kanidm" "dovecot2" "postfix" ]; services.roundcube = { enable = true; + # package = pkgs.roundcube.overrideAttrs (_: rec { + # version = "1.6.9"; + # src = pkgs.fetchurl { + # url = "https://github.com/roundcube/roundcubemail/releases/download/${version}/roundcubemail-${version}-complete.tar.gz"; + # sha256 = "sha256-thpfXCL4kMKZ6TWqz88IcGdpkNiuv/DWzf8HW/F8708="; + # }; + # # src = pkgs.fetchurl { + # # url = "https://github.com/roundcube/roundcubemail/archive/master/3a6e25a5b386e0d87427b934ccd2e0e282e0a74e.tar.gz"; + # # sha256 = "sha256-EpEI4E+r3reYbI/5rquia+zgz1+6k49lPChlp4QiZTE="; + # # }; + # postFixup = '' + # cp -v ${/data/sp/roundcubemail-1.6.9/program/include/rcmail_oauth.php} $out/program/include/rcmail_oauth.php + # cp -v ${/data/sp/roundcubemail-1.6.9/program/actions/login/oauth.php} $out/program/actions/login/oauth.php + # rm -r $out/program/localization/* + # ''; + # }); + # package = pkgs.runCommandNoCCLocal "roundcube-debug" {} '' + # cp -r --no-preserve=all ${pkgs.roundcube} $out + # cp -v ${/data/sp/roundcubemail-1.6.8/plugins/debug_logger/debug_logger.php} $out/plugins/debug_logger/debug_logger.php + # cp -v ${/data/sp/roundcubemail-1.6.8/program/include/rcmail_oauth.php} $out/program/include/rcmail_oauth.php + # ''; # this is the url of the vhost, not necessarily the same as the fqdn of # the mailserver hostName = "${cfg.subdomain}.${config.selfprivacy.domain}"; + # plugins = [ "debug_logger" ]; extraConfig = '' # starttls needed for authentication, so the fqdn required to match # the certificate - $config['smtp_server'] = "tls://${config.mailserver.fqdn}"; + $config['smtp_host'] = "tls://${config.mailserver.fqdn}"; # $config['smtp_user'] = "%u"; # $config['smtp_pass'] = "%p"; '' + lib.strings.optionalString auth-module.enable '' @@ -53,20 +151,62 @@ in $config['oauth_token_uri'] = 'https://${auth-fqdn}/oauth2/token'; $config['oauth_identity_uri'] = 'https://${auth-fqdn}/oauth2/openid/${oauth-client-id}/userinfo'; $config['oauth_scope'] = 'email profile openid'; + # $config['oauth_scope'] = 'email openid dovecotprofile'; $config['oauth_auth_parameters'] = []; $config['oauth_identity_fields'] = ['email']; - $config['oauth_login_redirect'] = true; + $config['oauth_login_redirect'] = false; $config['auto_create_user'] = true; + + $config['log_dir'] = '/tmp/roundcube'; + $config['log_driver'] = 'stdout'; + $config['log_errors'] = 1; + // Log SQL queries to /sql or to syslog + $config['sql_debug'] = true; + + // Log IMAP conversation to /imap or to syslog + $config['imap_debug'] = true; + $config['log_debug'] = true; + $config['oauth_debug'] = true; + + // Log LDAP conversation to /ldap or to syslog + $config['ldap_debug'] = true; + + // Log SMTP conversation to /smtp or to syslog + $config['smtp_debug'] = true; + + $config['debug_logger']['master'] = 'master'; + $config['debug_logger']['oauth'] = 'oauth'; + $config['debug_logger']['imap'] = 'imap'; + $config['debug_logger']['log'] = 'log'; + $config['debug_logger']['smtp'] = 'smtp'; + + $config['oauth_verify_peer'] = false; + $config['log_logins'] = true; + $config['log_session'] = true; + # $config['oauth_pkce'] = 'S256'; ''; }; services.nginx.virtualHosts."${cfg.subdomain}.${domain}" = { forceSSL = true; useACMEHost = domain; enableACME = false; + # extraConfig = '' + # add_header X-Frame-Options DENY; + # add_header X-Content-Type-Options nosniff; + # add_header X-XSS-Protection "1; mode=block"; + # ''; }; systemd = { services = { - phpfpm-roundcube.serviceConfig.Slice = lib.mkForce "roundcube.slice"; + phpfpm-roundcube.serviceConfig = { + Slice = lib.mkForce "roundcube.slice"; + StandardError = "journal"; + StandardOutput = "journal"; + }; + kanidm.serviceConfig.ExecStartPost = lib.mkAfter [ + ("+" + kanidmExecStartPostScriptRoot) + kanidmExecStartPostScript + ]; }; slices.roundcube = { description = "Roundcube service slice"; @@ -75,23 +215,33 @@ in services.kanidm.provision = lib.mkIf auth-module.enable { groups.roundcube_users.present = true; - systems.oauth2.roundcube = - { - displayName = "Roundcube"; - originUrl = "https://${cfg.subdomain}.${domain}/"; - originLanding = "https://${cfg.subdomain}.${domain}/"; - basicSecretFile = pkgs.writeText "bs-roundcube" "VERYSTRONGSECRETFORROUNDCUBE"; - # when true, name is passed to a service instead of name@domain - preferShortUsername = false; - allowInsecureClientDisablePkce = true; # FIXME is it needed? - scopeMaps.roundcube_users = [ - "email" - # "groups" - "profile" - "openid" - # "dovecotprofile" - ]; - }; + systems.oauth2.roundcube = { + displayName = "Roundcube"; + originUrl = "https://${cfg.subdomain}.${domain}/index.php/login/oauth"; + originLanding = "https://${cfg.subdomain}.${domain}/"; + basicSecretFile = pkgs.writeText "bs-roundcube" "VERYSTRONGSECRETFORROUNDCUBE"; + # when true, name is passed to a service instead of name@domain + preferShortUsername = false; + allowInsecureClientDisablePkce = true; # FIXME is it needed? + scopeMaps.roundcube_users = [ + "email" + "profile" + "openid" + ]; + # scopeMaps.roundcube_users = [ + # "email" + # "openid" + # "dovecotprofile" + # ]; + + # add more scopes when a user is a member of specific group + # claimMaps.groups = { + # joinType = "array"; + # valuesByGroup = { + # "sp.roundcube.admin" = [ "admin" ]; + # }; + # }; + }; }; }; }