diff --git a/auth/auth-module.nix b/auth/auth-module.nix new file mode 100644 index 0000000..fa831b5 --- /dev/null +++ b/auth/auth-module.nix @@ -0,0 +1,363 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) + mkOption + types + ; + auth-passthru = config.selfprivacy.passthru.auth; + keys-path = auth-passthru.keys-path; + # generate OAuth2 client secret + mkKanidmExecStartPreScript = oauthClientID: linuxGroup: + let + secretFP = auth-passthru.mkOAuth2ClientSecretFP linuxGroup; + in + pkgs.writeShellScript + "${oauthClientID}-kanidm-ExecStartPre-script.sh" '' + set -o pipefail + set -o errexit + if ! [ -f "${secretFP}" ] + then + "${lib.getExe pkgs.openssl}" rand -base64 32 \ + | tr "\n:@/+=" "012345" > "${secretFP}" + chmod 640 "${secretFP}" + fi + ''; + mkKanidmExecStartPostScript = oauthClientID: linuxGroup: isMailserver: + let + kanidmServiceAccountName = "sp.${oauthClientID}.service-account"; + kanidmServiceAccountTokenName = "${oauthClientID}-service-account-token"; + kanidmServiceAccountTokenFP = + auth-passthru.mkServiceAccountTokenFP linuxGroup; + isRW = oauthClientID == "selfprivacy-api"; + in + pkgs.writeShellScript + "${oauthClientID}-kanidm-ExecStartPost-script.sh" + ('' + export HOME=$RUNTIME_DIRECTORY/client_home + readonly KANIDM="${pkgs.kanidm}/bin/kanidm" + + # try to get existing Kanidm service account + KANIDM_SERVICE_ACCOUNT="$($KANIDM service-account list --name idm_admin | grep -E "^name: ${kanidmServiceAccountName}$")" + echo KANIDM_SERVICE_ACCOUNT: "$KANIDM_SERVICE_ACCOUNT" + if [ -n "$KANIDM_SERVICE_ACCOUNT" ] + then + echo "kanidm service account \"${kanidmServiceAccountName}\" is found" + else + echo "kanidm service account \"${kanidmServiceAccountName}\" is not found" + echo "creating new kanidm service account \"${kanidmServiceAccountName}\"" + if $KANIDM service-account create --name idm_admin "${kanidmServiceAccountName}" "${kanidmServiceAccountName}" idm_admin + then + echo "kanidm service account \"${kanidmServiceAccountName}\" created" + else + echo "error: cannot create kanidm service account \"${kanidmServiceAccountName}\"" + exit 1 + fi + fi + + # create a new token for kanidm + if ! KANIDM_SERVICE_ACCOUNT_TOKEN_JSON="$($KANIDM service-account api-token generate --name idm_admin "${kanidmServiceAccountName}" "${kanidmServiceAccountTokenName}" ${lib.strings.optionalString isRW "--rw"} --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 ! install --mode=640 \ + <(printf "%s" "$KANIDM_SERVICE_ACCOUNT_TOKEN") \ + ${kanidmServiceAccountTokenFP} + then + echo "error: cannot write token to \"${kanidmServiceAccountTokenFP}\"" + exit 1 + fi + + '' + + lib.strings.optionalString isMailserver '' + # add Kanidm service account to `idm_mail_servers` group + $KANIDM group add-members idm_mail_servers "${kanidmServiceAccountName}" + '' + + lib.strings.optionalString isRW '' + $KANIDM group add-members idm_admins "${kanidmServiceAccountName}" + '' + ); +in +{ + options.selfprivacy.auth = { + clients = mkOption { + description = + "Configurations for OAuth2 & LDAP servers clients services. Corresponding Kanidm provisioning configuration and systemd scripts are generated."; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + clientID = mkOption { + type = types.nullOr types.str; + description = '' + Name of this client service. Used as OAuth2 client ID and to form Kanidm sp.$\{clientID}.* group names. Defaults to attribute name in virtualHosts; + ''; + default = null; + }; + displayName = mkOption { + type = types.nullOr types.str; + description = "Display name showed in Kanidm Web GUI. Defaults to clientID."; + default = null; + }; + enablePkce = mkOption { + type = lib.types.bool; + description = + "Whether PKCE must be used between client and Kanidm."; + default = false; + }; + adminsGroup = mkOption { + type = + types.nullOr (lib.types.strMatching "sp\.[A-Za-z0-9]+\.admins"); + description = + "Name of admins group in Kanidm, whose members have admin level access to resources (service) associated with OAuth2 client authorization."; + default = null; + }; + usersGroup = mkOption { + type = + types.nullOr (lib.types.strMatching "sp\.[A-Za-z0-9]+\.users"); + description = + "Name of users group in Kanidm, whose members have user level access to resources (service) associated with OAuth2 client authorization."; + default = null; + }; + originLanding = mkOption { + type = types.nullOr lib.types.str; + description = + "The origin landing of the service for OAuth2 redirects."; + }; + originUrl = mkOption { + type = types.nullOr lib.types.str; + description = + "The origin URL of the service for OAuth2 redirects."; + }; + subdomain = lib.mkOption { + type = + lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Subdomain of the service."; + }; + # when true, "name" is passed to a service instead of "name@domain" + useShortPreferredUsername = mkOption { + description = + "Use 'name' instead of 'spn' in the preferred_username claim."; + type = types.bool; + default = true; + }; + linuxUserOfClient = mkOption { + type = types.nullOr lib.types.str; + description = + "Name of a Linux OAuth2 client user, under which it should get access through a folder with keys."; + default = null; + }; + linuxGroupOfClient = mkOption { + type = types.nullOr lib.types.str; + description = + "Name of Linux OAuth2 client group, under which it should read an OAuth2 client secret file."; + default = null; + }; + isTokenNeeded = mkOption { + description = + "Whether a read-only needs to be generated for LDAP access."; + type = types.bool; + default = false; + }; + clientSystemdUnits = mkOption { + description = "A list of systemd services, which depend on OAuth service"; + # taken from nixos/lib/systemd-lib.nix: unitNameType + type = types.listOf + (types.strMatching "[a-zA-Z0-9@%:_.\\-]+[.](service|socket|device|mount|automount|swap|target|path|timer|scope|slice)"); + }; + 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.nullOr (types.attrsOf (types.listOf types.str)); + default = null; + }; + 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); + }; + }; + } + ); + }; + imageFile = mkOption { + type = types.nullOr lib.types.path; + description = '' + Filepath of an image which is displayed in Kanidm web GUI for a service. + ''; + default = null; + }; + isMailserver = mkOption { + type = types.bool; + description = '' + Whether client is a mailserver. + ''; + default = false; + }; + }; + } + ); + }; + }; + config = lib.mkIf config.selfprivacy.sso.enable ( + let + clientsAttrsList = lib.attrsets.mapAttrsToList + (name: attrs: attrs // rec { + clientID = + if attrs.clientID == null + then name + else attrs.clientID; + displayName = + if attrs.displayName == null + then clientID + else attrs.displayName; + adminsGroup = + if attrs.adminsGroup == null + then "sp.${clientID}.admins" + else attrs.adminsGroup; + usersGroup = + if attrs.usersGroup == null + then "sp.${clientID}.users" + else attrs.usersGroup; + basicSecretFile = + "${keys-path}/${linuxGroupOfClient}/kanidm-oauth-client-secret"; + linuxUserOfClient = + if attrs.linuxUserOfClient == null + then clientID + else attrs.linuxUserOfClient; + linuxGroupOfClient = + if attrs.linuxGroupOfClient == null + then clientID + else attrs.linuxGroupOfClient; + originLanding = + if attrs.originLanding == null + then "https://${attrs.subdomain}.${config.selfprivacy.domain}/" + else attrs.originLanding; + scopeMaps = + if attrs.scopeMaps == null + then { "${usersGroup}" = [ "email" "openid" "profile" ]; } + else attrs.scopeMaps; + }) + config.selfprivacy.auth.clients; + in + { + # for each OAuth2 client: member of the `keys` group for directory access + users.groups.keys.members = lib.mkMerge (lib.forEach + clientsAttrsList + ({ linuxUserOfClient, ... }: [ linuxUserOfClient ]) + ); + + systemd.tmpfiles.settings."kanidm-secrets" = lib.mkMerge (lib.forEach + clientsAttrsList + ({ linuxGroupOfClient, ... }: { + "${keys-path}/${linuxGroupOfClient}".d = { + user = "kanidm"; + group = linuxGroupOfClient; + mode = "2750"; + }; + }) + ); + + # for each OAuth2 client: scripts with Kanidm CLI commands + systemd.services.kanidm = { + before = + lib.lists.concatMap + ({ clientSystemdUnits, ... }: clientSystemdUnits) + clientsAttrsList; + serviceConfig = + lib.mkMerge (lib.forEach + clientsAttrsList + ({ clientID, isTokenNeeded, linuxGroupOfClient, isMailserver, ... }: + { + ExecStartPre = [ + # "-" prefix means to ignore exit code of prefixed script + ("-" + mkKanidmExecStartPreScript clientID linuxGroupOfClient) + ]; + ExecStartPost = lib.mkIf isTokenNeeded + (lib.mkAfter [ + ("-" + + mkKanidmExecStartPostScript + clientID + linuxGroupOfClient + isMailserver) + ]); + })); + }; + + # for each OAuth2 client: Kanidm provisioning options + services.kanidm.provision = lib.mkMerge (lib.forEach + clientsAttrsList + ({ adminsGroup + , basicSecretFile + , claimMaps + , clientID + , displayName + , enablePkce + , imageFile + , originLanding + , originUrl + , scopeMaps + , useShortPreferredUsername + , usersGroup + , ... + }: { + groups = lib.mkIf (clientID != "selfprivacy-api") { + "${adminsGroup}".members = + [ auth-passthru.admins-group ]; + "${usersGroup}".members = + [ adminsGroup auth-passthru.full-users-group ]; + }; + systems.oauth2.${clientID} = { + inherit + basicSecretFile + claimMaps + displayName + imageFile + originLanding + originUrl + scopeMaps + ; + preferShortUsername = useShortPreferredUsername; + allowInsecureClientDisablePkce = ! enablePkce; + removeOrphanedClaimMaps = true; + + # NOTE https://github.com/oddlama/kanidm-provision/issues/15 + # add more scopes when a user is a member of specific group + # currently not possible due to https://github.com/kanidm/kanidm/issues/2882#issuecomment-2564490144 + # supplementaryScopeMaps."${admins-group}" = + # [ "read:admin" "write:admin" ]; + }; + })); + } + ); +} diff --git a/auth/auth.nix b/auth/auth.nix new file mode 100644 index 0000000..ca1e6bb --- /dev/null +++ b/auth/auth.nix @@ -0,0 +1,195 @@ +{ config, lib, pkgs, ... }: +let + domain = config.selfprivacy.domain; + subdomain = "auth"; + auth-fqdn = subdomain + "." + domain; + + ldap-host = "127.0.0.1"; + ldap-port = 3636; + + keys-path = "/run/keys"; + + admins-group = "sp.admins"; + full-users-group = "sp.full_users"; + + kanidm-bind-address = "127.0.0.1:3013"; + + selfprivacy-group = config.users.users."selfprivacy-api".group; + + kanidmMigrateDbScript = pkgs.writeShellScript "kanidm-db-migration-script" '' + # handle a case when kanidm database is not yet created (the first startup) + if [ -f ${config.services.kanidm.serverSettings.db_path} ] + then + set -o xtrace + # since it's the last command, it produces an exit code for systemd as well + ${lib.getExe pkgs.sqlite} ${config.services.kanidm.serverSettings.db_path} < ${./kanidm-db-migration.sql} + fi + ''; + + # lua stuff for nginx for debugging only + 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};"; +in +lib.mkIf config.selfprivacy.sso.enable { + networking.hosts = { + # Allow the services to communicate with kanidm even if + # there is no DNS record yet + "127.0.0.1" = [ auth-fqdn ]; + }; + + + # kanidm uses TLS in internal connection with nginx too + # FIXME revise this: maybe kanidm must not have access to a public TLS + users.groups."acmereceivers".members = [ "kanidm" ]; + + # for ExecStartPost scripts to have access to /run/keys/* + users.groups.keys.members = [ "kanidm" ]; + + systemd.tmpfiles.settings."kanidm-secrets" = { + "${keys-path}/${selfprivacy-group}".d = { + user = "kanidm"; + group = selfprivacy-group; + mode = "2750"; + }; + }; + + services.kanidm = { + enableServer = true; + + # kanidm with Rust code patches for OAuth and admin passwords provisioning + package = pkgs.kanidm.withSecretProvisioning; + + serverSettings = { + inherit domain; + # The origin for webauthn. This is the url to the server, with the port + # included if it is non-standard (any port except 443). This must match or + # be a descendent of the domain name you configure above. If these two + # items are not consistent, the server WILL refuse to start! + origin = "https://" + auth-fqdn; + + # TODO revise this: maybe kanidm must not have access to a public TLS + tls_chain = + "${config.security.acme.certs.${domain}.directory}/fullchain.pem"; + tls_key = + "${config.security.acme.certs.${domain}.directory}/key.pem"; + + # nginx should proxy requests to it + bindaddress = kanidm-bind-address; + + ldapbindaddress = + "${ldap-host}:${toString ldap-port}"; + + # kanidm is behind a proxy + trust_x_forward_for = true; + + log_level = if config.selfprivacy.sso.debug then "trace" else "info"; + }; + provision = { + enable = true; + autoRemove = true; # if false, obsolete oauth2 scopeMaps remain + groups.${admins-group}.present = true; + groups.${full-users-group}.present = true; + }; + enableClient = true; + clientSettings = { + uri = "https://" + auth-fqdn; + verify_ca = false; # FIXME + verify_hostnames = false; # FIXME + }; + }; + + services.nginx = { + enable = true; + additionalModules = + lib.mkIf config.selfprivacy.sso.debug [ pkgs.nginxModules.lua ]; + commonHttpConfig = lib.mkIf config.selfprivacy.sso.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."/" = { + extraConfig = lib.mkIf config.selfprivacy.sso.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 + if type(v) == "table" then + ngx.var.req_header = ngx.var.req_header .. k .. "=" .. table.concat(v, ", ") .. " " + else + ngx.var.req_header = ngx.var.req_header .. k .. "=" .. v .. " " + end + end + local rh = ngx.resp.get_headers() + for k, v in pairs(rh) do + if type(v) == "table" then + ngx.var.resp_header = ngx.var.resp_header .. k .. "=" .. table.concat(v, ", ") .. " " + else + ngx.var.resp_header = ngx.var.resp_header .. k .. "=" .. v .. " " + end + 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}"; + }; + }; + }; + + systemd.services.kanidm.serviceConfig.ExecStartPre = + # idempotent script to run on each startup only for kanidm v1.5.0 + lib.mkIf (pkgs.kanidm.version == "1.5.0") + (lib.mkBefore [ kanidmMigrateDbScript ]); + + selfprivacy.passthru.auth = { + inherit + admins-group + auth-fqdn + full-users-group + ldap-host + ldap-port + keys-path + ; + oauth2-introspection-url-prefix = client_id: "https://${client_id}:"; + oauth2-introspection-url-postfix = + "@${auth-fqdn}/oauth2/token/introspect"; + oauth2-discovery-url = client_id: + "https://${auth-fqdn}/oauth2/openid/${client_id}/.well-known/openid-configuration"; + oauth2-provider-name = "Kanidm"; + oauth2-systemd-service = "kanidm.service"; + + # e.g. "dc=mydomain,dc=com" + ldap-base-dn = + lib.strings.concatMapStringsSep + "," + (x: "dc=" + x) + (lib.strings.splitString "." domain); + + # TODO consider to pass a value or throw exception if token is not generated + mkServiceAccountTokenFP = linuxGroup: + "${keys-path}/${linuxGroup}/kanidm-service-account-token"; + + mkOAuth2ClientSecretFP = linuxGroup: + "${keys-path}/${linuxGroup}/kanidm-oauth-client-secret"; + }; +} diff --git a/auth/kanidm-db-migration.sql b/auth/kanidm-db-migration.sql new file mode 100644 index 0000000..0af27ea --- /dev/null +++ b/auth/kanidm-db-migration.sql @@ -0,0 +1,56 @@ +update id2entry +set data = cast( + json_replace( + data, + '$.ent.V3.attrs.acp_create_attr.I8', + json_array( + 'class','description','displayname','image','name', + 'oauth2_allow_insecure_client_disable_pkce', + 'oauth2_allow_localhost_redirect', + 'oauth2_device_flow_enable', + 'oauth2_jwt_legacy_crypto_enable', + 'oauth2_prefer_short_username', + 'oauth2_rs_claim_map', + 'oauth2_rs_basic_secret', + 'oauth2_rs_name', + 'oauth2_rs_origin', + 'oauth2_rs_origin_landing', + 'oauth2_rs_scope_map', + 'oauth2_rs_sup_scope_map', + 'oauth2_strict_redirect_uri' + ) + ) as blob +) +where cast (id as text) = ( + select json_extract(idl, '$.t.s[0]') + from idx_eq_name + where key = 'idm_acp_oauth2_manage' +); + +update id2entry +set data = cast( + json_replace( + data, + '$.ent.V3.attrs.acp_modify_presentattr.I8', + json_array( + 'description','displayname','image','name', + 'oauth2_allow_insecure_client_disable_pkce', + 'oauth2_allow_localhost_redirect', + 'oauth2_device_flow_enable', + 'oauth2_jwt_legacy_crypto_enable', + 'oauth2_prefer_short_username', + 'oauth2_rs_claim_map', + 'oauth2_rs_basic_secret', + 'oauth2_rs_origin', + 'oauth2_rs_origin_landing', + 'oauth2_rs_scope_map', + 'oauth2_rs_sup_scope_map', + 'oauth2_strict_redirect_uri' + ) + ) as blob +) +where cast (id as text) = ( + select json_extract(idl, '$.t.s[0]') + from idx_eq_name + where key = 'idm_acp_oauth2_manage' +); diff --git a/auth/kanidm.nix b/auth/kanidm.nix new file mode 100644 index 0000000..7290374 --- /dev/null +++ b/auth/kanidm.nix @@ -0,0 +1,1048 @@ +{ 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); + + selfprivacy-admin-groups-regex = "^sp\.([[:alnum:]]+\.|)admins$"; + is-selfprivacy-admin-group = name: + ! builtins.isNull (builtins.match selfprivacy-admin-groups-regex name); + + isGroupNonOverwritable = g: false + || ! g ? members + || g ? members && g.members == [ ] + || g ? members && builtins.any is-selfprivacy-admin-group g.members; + + provisionStateJson = pkgs.writeText "provision-state.json" ( + builtins.toJSON { + inherit (cfg.provision) persons systems; + groups = + lib.attrsets.filterAttrs (_n: v: ! isGroupNonOverwritable 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 + ''; + + groupsToCreateAndPopulate = + lib.attrsets.filterAttrs (_n: isGroupNonOverwritable) cfg.provision.groups; + + createGroups = '' + for group_name in ${lib.strings.concatStringsSep " " (builtins.attrNames groupsToCreateAndPopulate)} + do + ${cfg.package}/bin/kanidm group create "$group_name" + done + ''; + + populateGroup = group_name: group: '' + for member_name in ${lib.strings.concatStringsSep " " group.members} + do + ${cfg.package}/bin/kanidm group add-members "${group_name}" "$member_name" + done + ''; + + createAndPopulateGroups = + lib.concatLines ([ createGroups ] + ++ (lib.mapAttrsToList populateGroup groupsToCreateAndPopulate)); + + 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} + + readonly CLIENT_HOME=$RUNTIME_DIRECTORY/client_home + mkdir -p "$CLIENT_HOME" + export HOME="$CLIENT_HOME" + export KANIDM_NAME=idm_admin + export KANIDM_URL="${cfg.provision.instanceUrl}" + export KANIDM_SKIP_HOSTNAME_VERIFICATION="true" + KANIDM_PASSWORD="$KANIDM_IDM_ADMIN_PASSWORD" ${cfg.package}/bin/kanidm login + ${createAndPopulateGroups} + unset HOME + unset KANIDM_NAME + unset KANIDM_URL + unset KANIDM_SKIP_HOSTNAME_VERIFICATION + + 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; + }; + + imageFile = mkOption { + description = '' + Application image to display in the WebUI. + Kanidm supports "image/jpeg", "image/png", "image/gif", "image/svg+xml", and "image/webp". + The image will be uploaded each time kanidm-provision is run. + ''; + type = types.nullOr types.path; + 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) ++ [ "idm_all_persons" ]; + 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/configuration.nix b/configuration.nix index 82b2648..4fc9baa 100644 --- a/configuration.nix +++ b/configuration.nix @@ -32,6 +32,7 @@ in { imports = [ ./selfprivacy-module.nix + ./auth/auth-module.nix ./volumes.nix ./users.nix ./letsencrypt/acme.nix @@ -98,9 +99,8 @@ in services.openssh = { enable = config.selfprivacy.ssh.enable; settings = { - PasswordAuthentication = config.selfprivacy.ssh.passwordAuthentication; + PasswordAuthentication = false; PermitRootLogin = "prohibit-password"; - LoginGraceTime = 0; }; openFirewall = false; diff --git a/flake.lock b/flake.lock index c6dab8e..f83eba7 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "nixos-unstable": { + "locked": { + "lastModified": 1744463964, + "narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1734835170, @@ -17,6 +33,7 @@ }, "root": { "inputs": { + "nixos-unstable": "nixos-unstable", "nixpkgs": "nixpkgs", "selfprivacy-api": "selfprivacy-api" } @@ -28,11 +45,11 @@ ] }, "locked": { - "lastModified": 1735059871, - "narHash": "sha256-1UExQNlKFXPv+D7HEAICElIVitl8yU920Ox83qF9hAY=", + "lastModified": 1745582811, + "narHash": "sha256-UxglxWAqX/yQBn7065kbQyd2LGEQrnxkR+9WjJt2fWM=", "ref": "master", - "rev": "043d280d53fc56a2cd4f60a12f1d7ccf03ec2f78", - "revCount": 1474, + "rev": "3bb5e09588c0a60149eea520844fb80eb86f8bdb", + "revCount": 1772, "type": "git", "url": "https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git" }, diff --git a/flake.nix b/flake.nix index 27c0444..d18df25 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,7 @@ inputs = { nixpkgs.url = github:nixos/nixpkgs; + nixos-unstable.url = github:nixos/nixpkgs/nixos-unstable; selfprivacy-api.url = git+https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git; @@ -10,7 +11,7 @@ selfprivacy-api.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { self, nixpkgs, selfprivacy-api }: { + outputs = { self, nixpkgs, nixos-unstable, selfprivacy-api }: { nixosConfigurations-fun = { hardware-configuration , deployment @@ -24,6 +25,22 @@ hardware-configuration deployment ./configuration.nix + ./auth/auth.nix + { + nixpkgs.overlays = [ + ( + _final: prev: + { + inherit (nixos-unstable.legacyPackages.${prev.system}) + kanidm + kanidm-provision + ; + } + ) + ]; + disabledModules = [ "services/security/kanidm.nix" ]; + imports = [ ./auth/kanidm.nix ]; + } selfprivacy-api.nixosModules.default ({ pkgs, lib, ... }: { environment.etc = (lib.attrsets.mapAttrs' diff --git a/selfprivacy-module.nix b/selfprivacy-module.nix index 21fd25f..7aeba84 100644 --- a/selfprivacy-module.nix +++ b/selfprivacy-module.nix @@ -34,6 +34,18 @@ with lib; type = types.nullOr types.bool; }; }; + sso = { + enable = mkOption { + description = "Enable SSO."; + default = true; + type = types.nullOr types.bool; + }; + debug = mkOption { + description = "Enable debug for SSO."; + default = false; + type = types.nullOr types.bool; + }; + }; stateVersion = mkOption { description = "State version of the server"; type = types.nullOr types.str; @@ -96,13 +108,6 @@ with lib; type = types.nullOr (types.listOf types.str); default = [ "" ]; }; - passwordAuthentication = mkOption { - description = '' - Password authentication for SSH - ''; - default = false; - type = types.nullOr types.bool; - }; }; ########### # Users # @@ -139,5 +144,32 @@ with lib; default = null; }; }; + ################ + # passthrough # + ################ + passthru = mkOption { + type = types.submodule { + freeformType = with types; lazyAttrsOf (uniq unspecified); + options = { }; + }; + default = { }; + visible = false; + description = '' + This attribute allows to share data between modules. + You can put whatever you want here. + ''; + }; + ################# + # Workarounds # + ################# + workarounds = { + deleteNextcloudAdmin = mkOption { + description = '' + Whether to delete an admin user, which is initially created + ''; + type = types.bool; + default = false; + }; + }; }; } diff --git a/sp-modules/gitea/config-paths-needed.json b/sp-modules/gitea/config-paths-needed.json index cdbf856..63ea4fe 100644 --- a/sp-modules/gitea/config-paths-needed.json +++ b/sp-modules/gitea/config-paths-needed.json @@ -1,5 +1,15 @@ [ [ "selfprivacy", "domain" ], + [ "selfprivacy", "modules", "auth", "enable" ], + [ "selfprivacy", "modules", "gitea" ], + [ "selfprivacy", "passthru", "auth", "ldap-base-dn" ], + [ "selfprivacy", "passthru", "auth", "ldap-host" ], + [ "selfprivacy", "passthru", "auth", "ldap-port" ], + [ "selfprivacy", "passthru", "auth", "mkOAuth2ClientSecretFP" ], + [ "selfprivacy", "passthru", "auth", "mkServiceAccountTokenFP" ], + [ "selfprivacy", "passthru", "auth", "oauth2-discovery-url" ], + [ "selfprivacy", "passthru", "auth", "oauth2-provider-name" ], + [ "selfprivacy", "sso", "enable" ], [ "selfprivacy", "useBinds" ], - [ "selfprivacy", "modules", "gitea" ] + [ "services", "forgejo", "package" ] ] diff --git a/sp-modules/gitea/flake.nix b/sp-modules/gitea/flake.nix index 7ee82ba..f18fa21 100644 --- a/sp-modules/gitea/flake.nix +++ b/sp-modules/gitea/flake.nix @@ -26,6 +26,10 @@ homepage = "https://forgejo.org"; sourcePage = "https://codeberg.org/forgejo/forgejo"; supportLevel = "normal"; + sso = { + userGroup = "sp.gitea.users"; + adminGroup = "sp.gitea.admins"; + }; }; }; } diff --git a/sp-modules/gitea/module.nix b/sp-modules/gitea/module.nix index 8fc063c..969ba47 100644 --- a/sp-modules/gitea/module.nix +++ b/sp-modules/gitea/module.nix @@ -14,6 +14,26 @@ let "gitea-light" "gitea-dark" ]; + is-auth-enabled = cfg.enableSso && config.selfprivacy.sso.enable; + oauthClientID = "forgejo"; + auth-passthru = config.selfprivacy.passthru.auth; + oauth2-provider-name = auth-passthru.oauth2-provider-name; + redirect-uri = + "https://${cfg.subdomain}.${sp.domain}/user/oauth2/${oauth2-provider-name}/callback"; + oauthDiscoveryURL = auth-passthru.oauth2-discovery-url oauthClientID; + + # SelfPrivacy uses SP Module ID to identify the group! + adminsGroup = "sp.gitea.admins"; + usersGroup = "sp.gitea.users"; + + linuxUserOfService = "gitea"; + linuxGroupOfService = "gitea"; + forgejoPackage = pkgs.forgejo; + + serviceAccountTokenFP = + auth-passthru.mkServiceAccountTokenFP linuxGroupOfService; + oauthClientSecretFP = + auth-passthru.mkOAuth2ClientSecretFP linuxGroupOfService; in { options.selfprivacy.modules.gitea = { @@ -107,115 +127,284 @@ in weight = 6; }; }; - }; - - config = lib.mkIf cfg.enable { - fileSystems = lib.mkIf sp.useBinds { - "/var/lib/gitea" = { - device = "/volumes/${cfg.location}/gitea"; - options = [ "bind" ]; + enableSso = (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Single Sign-On"; + }) // { + meta = { + type = "bool"; + weight = 7; }; }; - services.gitea.enable = false; - services.forgejo = { - enable = true; - package = pkgs.forgejo; - inherit stateDir; - user = "gitea"; - group = "gitea"; - database = { - type = "sqlite3"; - host = "127.0.0.1"; - name = "gitea"; - user = "gitea"; - path = "${stateDir}/data/gitea.db"; - createDatabase = true; + debug = (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable debug logging"; + }) // { + meta = { + type = "bool"; + weight = 8; }; - # ssh = { - # enable = true; - # clonePort = 22; - # }; - lfs = { - enable = cfg.enableLfs; - contentDir = "${stateDir}/lfs"; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + assertions = [ + { + assertion = cfg.enableSso -> sp.sso.enable; + message = + "SSO cannot be enabled for Forgejo when SSO is disabled globally."; + } + ]; + fileSystems = lib.mkIf sp.useBinds { + "/var/lib/gitea" = { + device = "/volumes/${cfg.location}/gitea"; + options = [ "bind" ]; + }; }; - repositoryRoot = "${stateDir}/repositories"; - # cookieSecure = true; - settings = { - DEFAULT = { - APP_NAME = "${cfg.appName}"; + services.gitea.enable = false; + services.forgejo = { + enable = true; + package = forgejoPackage; + inherit stateDir; + user = linuxUserOfService; + group = linuxGroupOfService; + database = { + type = "sqlite3"; + host = "127.0.0.1"; + name = "gitea"; + user = linuxUserOfService; + path = "${stateDir}/data/gitea.db"; + createDatabase = true; }; - server = { - DOMAIN = "${cfg.subdomain}.${sp.domain}"; - ROOT_URL = "https://${cfg.subdomain}.${sp.domain}/"; - HTTP_ADDR = "0.0.0.0"; - HTTP_PORT = 3000; + # ssh = { + # enable = true; + # clonePort = 22; + # }; + lfs = { + enable = cfg.enableLfs; + contentDir = "${stateDir}/lfs"; }; - mailer = { - ENABLED = false; + repositoryRoot = "${stateDir}/repositories"; + # cookieSecure = true; + settings = { + DEFAULT = { + APP_NAME = "${cfg.appName}"; + }; + server = { + DOMAIN = "${cfg.subdomain}.${sp.domain}"; + ROOT_URL = "https://${cfg.subdomain}.${sp.domain}/"; + HTTP_ADDR = "0.0.0.0"; + HTTP_PORT = 3000; + }; + mailer = { + ENABLED = false; + }; + ui = { + DEFAULT_THEME = cfg.defaultTheme; + SHOW_USER_EMAIL = false; + }; + picture = { + DISABLE_GRAVATAR = true; + }; + admin = { + ENABLE_KANBAN_BOARD = true; + }; + repository = { + FORCE_PRIVATE = cfg.forcePrivate; + }; + session = { + COOKIE_SECURE = true; + }; + log = { + ROOT_PATH = "${stateDir}/log"; + LEVEL = if cfg.debug then "Warn" else "Trace"; + }; + service = { + DISABLE_REGISTRATION = cfg.disableRegistration; + REQUIRE_SIGNIN_VIEW = cfg.requireSigninView; + }; }; - ui = { - DEFAULT_THEME = cfg.defaultTheme; - SHOW_USER_EMAIL = false; + }; + + users.users.gitea = { + home = "${stateDir}"; + useDefaultShell = true; + group = linuxGroupOfService; + isSystemUser = true; + }; + users.groups.${linuxGroupOfService} = { }; + services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = { + useACMEHost = sp.domain; + forceSSL = true; + extraConfig = '' + add_header Strict-Transport-Security $hsts_header; + #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always; + add_header 'Referrer-Policy' 'origin-when-cross-origin'; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + ''; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:3000"; + }; }; - picture = { - DISABLE_GRAVATAR = true; + }; + systemd = { + services.forgejo = { + unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/gitea"; + serviceConfig = { + Slice = "gitea.slice"; + }; }; - admin = { - ENABLE_KANBAN_BOARD = true; - }; - repository = { - FORCE_PRIVATE = cfg.forcePrivate; - }; - session = { - COOKIE_SECURE = true; - }; - log = { - ROOT_PATH = "${stateDir}/log"; - LEVEL = "Warn"; + slices.gitea = { + description = "Forgejo service slice"; }; + }; + } + # the following part is active only when enableSso = true + (lib.mkIf is-auth-enabled { + services.forgejo.settings = { + auth.DISABLE_LOGIN_FORM = true; service = { DISABLE_REGISTRATION = cfg.disableRegistration; REQUIRE_SIGNIN_VIEW = cfg.requireSigninView; + ALLOW_ONLY_EXTERNAL_REGISTRATION = true; + SHOW_REGISTRATION_BUTTON = false; + ENABLE_BASIC_AUTHENTICATION = false; }; - }; - }; - users.users.gitea = { - home = "${stateDir}"; - useDefaultShell = true; - group = "gitea"; - isSystemUser = true; - }; - users.groups.gitea = { }; - services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = { - useACMEHost = sp.domain; - forceSSL = true; - extraConfig = '' - add_header Strict-Transport-Security $hsts_header; - #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always; - add_header 'Referrer-Policy' 'origin-when-cross-origin'; - add_header X-Frame-Options DENY; - add_header X-Content-Type-Options nosniff; - add_header X-XSS-Protection "1; mode=block"; - proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; - ''; - locations = { - "/" = { - proxyPass = "http://127.0.0.1:3000"; - }; - }; - }; - systemd = { - services.forgejo = { - unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/gitea"; - serviceConfig = { - Slice = "gitea.slice"; - }; - }; - slices.gitea = { - description = "Forgejo service slice"; - }; - }; - }; + # disallow explore page and access to private repositories, but allow public + "service.explore".REQUIRE_SIGNIN_VIEW = true; + + # TODO control via selfprivacy parameter + # "service.explore".DISABLE_USERS_PAGE = true; + + oauth2_client = { + REDIRECT_URI = redirect-uri; + ACCOUNT_LINKING = "auto"; + ENABLE_AUTO_REGISTRATION = true; + OPENID_CONNECT_SCOPES = "email openid profile"; + }; + # doesn't work if LDAP auth source is not active! + "cron.sync_external_users" = { + ENABLED = true; + RUN_AT_START = true; + NOTICE_ON_SUCCESS = true; + }; + }; + systemd.services.forgejo = { + preStart = + let + waitForURL = url: maxRetries: delaySec: '' + for ((i=1; i<=${toString maxRetries}; i++)) + do + if ${lib.getExe pkgs.curl} -X GET --silent --fail "${url}" > /dev/null + then + echo "${url} responds to GET HTTP request (attempt #$i)" + break + else + echo "${url} does not respond to GET HTTP request (attempt #$i)" + echo sleeping for ${toString delaySec} seconds + fi + sleep ${toString delaySec} + done + if [[ "$i" -gt "${toString maxRetries}" ]] + then + echo "error, max attempts to access "${url}" have been used unsuccessfully!" + exit 124 + fi + ''; + + exe = lib.getExe config.services.forgejo.package; + # FIXME skip-tls-verify, bind-password + ldapConfigArgs = '' + --name LDAP \ + --active \ + --security-protocol LDAPS \ + --skip-tls-verify \ + --host '${auth-passthru.ldap-host}' \ + --port '${toString auth-passthru.ldap-port}' \ + --user-search-base '${auth-passthru.ldap-base-dn}' \ + --user-filter '(&(class=person)(memberof=${usersGroup})(name=%s))' \ + --admin-filter '(&(class=person)(memberof=${adminsGroup})' \ + --username-attribute name \ + --firstname-attribute name \ + --surname-attribute displayname \ + --email-attribute mail \ + --public-ssh-key-attribute sshPublicKey \ + --bind-dn 'dn=token' \ + --bind-password "$(< ${serviceAccountTokenFP})" \ + --synchronize-users + ''; + oauthConfigArgs = '' + --name "${oauth2-provider-name}" \ + --provider openidConnect \ + --key forgejo \ + --secret "$(< ${oauthClientSecretFP})" \ + --group-claim-name groups \ + --admin-group admins \ + --auto-discover-url '${oauthDiscoveryURL}' + ''; + in + lib.mkMerge [ + (waitForURL oauthDiscoveryURL 10 10) + (lib.mkAfter '' + set -o xtrace + + # Check if LDAP is already configured + ldap_line="$(${exe} admin auth list | grep LDAP | head -n 1)" + + if [[ -n "$ldap_line" ]]; then + # update ldap config + id="$(echo "$ldap_line" | ${pkgs.gawk}/bin/awk '{print $1}')" + ${exe} admin auth update-ldap --id "$id" ${ldapConfigArgs} + else + # initially configure ldap + ${exe} admin auth add-ldap ${ldapConfigArgs} + fi + + oauth_line="$(${exe} admin auth list | grep "${oauth2-provider-name}" | head -n 1)" + if [[ -n "$oauth_line" ]]; then + id="$(echo "$oauth_line" | ${pkgs.gawk}/bin/awk '{print $1}')" + ${exe} admin auth update-oauth --id "$id" ${oauthConfigArgs} + else + ${exe} admin auth add-oauth ${oauthConfigArgs} + fi + '') + ]; + }; + + services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = { + extraConfig = lib.mkAfter '' + rewrite ^/user/login$ /user/oauth2/${oauth2-provider-name} last; + # FIXME is it needed? + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + + selfprivacy.auth.clients."${oauthClientID}" = { + inherit adminsGroup usersGroup; + imageFile = ./icon.svg; + displayName = "Forgejo"; + subdomain = cfg.subdomain; + isTokenNeeded = true; + originLanding = + "https://${cfg.subdomain}.${sp.domain}/user/login?redirect_to=%2f"; + originUrl = redirect-uri; + clientSystemdUnits = [ "forgejo.service" ]; + enablePkce = lib.versionAtLeast forgejoPackage.version "8.0"; + linuxUserOfClient = linuxUserOfService; + linuxGroupOfClient = linuxGroupOfService; + claimMaps.groups = { + joinType = "array"; + valuesByGroup.${adminsGroup} = [ "admins" ]; + }; + }; + }) + ]); } diff --git a/sp-modules/nextcloud/cleanup-module.nix b/sp-modules/nextcloud/cleanup-module.nix index 24d8fd5..0b3b3ae 100644 --- a/sp-modules/nextcloud/cleanup-module.nix +++ b/sp-modules/nextcloud/cleanup-module.nix @@ -1,6 +1,11 @@ { config, lib, ... }: let - inherit (import ./common.nix config) sp db-pass-filepath admin-pass-filepath; + inherit (import ./common.nix config) + admin-pass-filepath + db-pass-filepath + override-config-fp + sp + ; in # FIXME do we really want to delete passwords on module deactivation!? { @@ -9,11 +14,13 @@ in lib.trivial.warn ( "nextcloud service is disabled, " + - "${db-pass-filepath} and ${admin-pass-filepath} will be removed!" + "${override-config-fp}, ${db-pass-filepath} and ${admin-pass-filepath} will be removed!" ) '' rm -f -v ${db-pass-filepath} rm -f -v ${admin-pass-filepath} + [[ ! -f "${override-config-fp}" && -L "${override-config-fp}" ]] && \ + rm -v "${override-config-fp}" ''; }; } diff --git a/sp-modules/nextcloud/common.nix b/sp-modules/nextcloud/common.nix index 30f47e6..3fb4709 100644 --- a/sp-modules/nextcloud/common.nix +++ b/sp-modules/nextcloud/common.nix @@ -1,6 +1,8 @@ config: rec { sp = config.selfprivacy; + domain= sp.domain; secrets-filepath = "/etc/selfprivacy/secrets.json"; db-pass-filepath = "/var/lib/nextcloud/db-pass"; admin-pass-filepath = "/var/lib/nextcloud/admin-pass"; + override-config-fp = "/var/lib/nextcloud/config/override.config.php"; } diff --git a/sp-modules/nextcloud/config-paths-needed.json b/sp-modules/nextcloud/config-paths-needed.json index 5c2a9fc..0964f8d 100644 --- a/sp-modules/nextcloud/config-paths-needed.json +++ b/sp-modules/nextcloud/config-paths-needed.json @@ -1,5 +1,23 @@ [ + [ "security", "acme", "certs" ], [ "selfprivacy", "domain" ], + [ "selfprivacy", "modules", "auth", "enable" ], + [ "selfprivacy", "modules", "nextcloud" ], + [ "selfprivacy", "passthru", "auth", "admins-group" ], + [ "selfprivacy", "passthru", "auth", "full-users-group" ], + [ "selfprivacy", "passthru", "auth", "ldap-base-dn" ], + [ "selfprivacy", "passthru", "auth", "ldap-host" ], + [ "selfprivacy", "passthru", "auth", "ldap-port" ], + [ "selfprivacy", "passthru", "auth", "mkOAuth2ClientSecretFP" ], + [ "selfprivacy", "passthru", "auth", "mkServiceAccountTokenFP" ], + [ "selfprivacy", "passthru", "auth", "oauth2-discovery-url" ], + [ "selfprivacy", "passthru", "auth", "oauth2-provider-name" ], + [ "selfprivacy", "passthru", "auth", "oauth2-systemd-service" ], + [ "selfprivacy", "workarounds", "deleteNextcloudAdmin" ], + [ "selfprivacy", "sso", "enable" ], [ "selfprivacy", "useBinds" ], - [ "selfprivacy", "modules", "nextcloud" ] + [ "services", "nextcloud" ], + [ "services", "phpfpm", "pools", "nextcloud", "group" ], + [ "services", "phpfpm", "pools", "nextcloud", "user" ], + [ "systemd", "services", "nextcloud" ] ] diff --git a/sp-modules/nextcloud/flake.nix b/sp-modules/nextcloud/flake.nix index 41ee276..6e171a7 100644 --- a/sp-modules/nextcloud/flake.nix +++ b/sp-modules/nextcloud/flake.nix @@ -28,6 +28,10 @@ homepage = "https://nextcloud.com/"; sourcePage = "https://github.com/nextcloud"; supportLevel = "normal"; + sso = { + userGroup = "sp.nextcloud.users"; + adminGroup = "sp.nextcloud.admins"; + }; }; }; } diff --git a/sp-modules/nextcloud/module.nix b/sp-modules/nextcloud/module.nix index 12ad007..c58c036 100644 --- a/sp-modules/nextcloud/module.nix +++ b/sp-modules/nextcloud/module.nix @@ -1,4 +1,37 @@ { config, lib, pkgs, ... }: +let + inherit (import ./common.nix config) + admin-pass-filepath + db-pass-filepath + domain + override-config-fp + secrets-filepath + sp + ; + + hostName = "${cfg.subdomain}.${sp.domain}"; + auth-passthru = config.selfprivacy.passthru.auth; + deleteNextcloudAdmin = config.selfprivacy.workarounds.deleteNextcloudAdmin; + cfg = sp.modules.nextcloud; + is-auth-enabled = cfg.enableSso && config.selfprivacy.sso.enable; + ldap_scheme_and_host = "ldaps://${auth-passthru.ldap-host}"; + + occ = "${config.services.nextcloud.occ}/bin/nextcloud-occ"; + + linuxUserOfService = config.services.phpfpm.pools.nextcloud.user; + linuxGroupOfService = config.services.phpfpm.pools.nextcloud.group; + + oauthClientID = "nextcloud"; + + adminsGroup = "sp.${oauthClientID}.admins"; + usersGroup = "sp.${oauthClientID}.users"; + wildcardGroup = "sp.${oauthClientID}.*"; + + serviceAccountTokenFP = + auth-passthru.mkServiceAccountTokenFP linuxUserOfService; + oauthClientSecretFP = + auth-passthru.mkOAuth2ClientSecretFP linuxUserOfService; +in { options.selfprivacy.modules.nextcloud = with lib; { enable = (lib.mkOption { @@ -40,16 +73,38 @@ weight = 1; }; }; + enableSso = (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Single Sign-On"; + }) // { + meta = { + type = "bool"; + weight = 2; + }; + }; + debug = (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable debug logging"; + }) // { + meta = { + type = "bool"; + weight = 4; + }; + }; }; - config = - let - inherit (import ./common.nix config) - sp secrets-filepath db-pass-filepath admin-pass-filepath; - cfg = sp.modules.nextcloud; - hostName = "${cfg.subdomain}.${sp.domain}"; - in - lib.mkIf sp.modules.nextcloud.enable { + # config = lib.mkIf sp.modules.nextcloud.enable + config = lib.mkIf sp.modules.nextcloud.enable (lib.mkMerge [ + { + assertions = [ + { + assertion = cfg.enableSso -> sp.sso.enable; + message = + "SSO cannot be enabled for Nextcloud when SSO is disabled globally."; + } + ]; fileSystems = lib.mkIf sp.useBinds { "/var/lib/nextcloud" = { device = "/volumes/${cfg.location}/nextcloud"; @@ -62,10 +117,22 @@ ]; }; }; + + # for ExecStartPost script to have access to /run/keys/* + users.groups.keys.members = + lib.mkIf is-auth-enabled [ linuxUserOfService ]; + + # not needed, due to turnOffCertCheck=1 in used_ldap + # users.groups.${config.security.acme.certs.${domain}.group}.members = + # [ config.services.phpfpm.pools.nextcloud.user ]; + systemd = { services = { phpfpm-nextcloud.serviceConfig.Slice = lib.mkForce "nextcloud.slice"; - nextcloud-setup.serviceConfig.Slice = "nextcloud.slice"; + nextcloud-setup = { + serviceConfig.Slice = "nextcloud.slice"; + serviceConfig.Group = config.services.phpfpm.pools.nextcloud.group; + }; nextcloud-cron.serviceConfig.Slice = "nextcloud.slice"; nextcloud-update-db.serviceConfig.Slice = "nextcloud.slice"; nextcloud-update-plugins.serviceConfig.Slice = "nextcloud.slice"; @@ -94,7 +161,7 @@ }; services.nextcloud = { enable = true; - package = pkgs.nextcloud29; + package = pkgs.nextcloud30; inherit hostName; # Use HTTPS for links @@ -105,9 +172,28 @@ # set what time makes sense for you autoUpdateApps.startAt = "05:00:00"; + phpOptions.display_errors = "Off"; + settings = { # further forces Nextcloud to use HTTPS overwriteprotocol = "https"; + } // lib.attrsets.optionalAttrs is-auth-enabled { + loglevel = 0; + # log_type = "file"; + social_login_auto_redirect = false; + + allow_local_remote_servers = true; + allow_user_to_change_display_name = false; + lost_password_link = "disabled"; + allow_multiple_user_backends = false; + + user_oidc = { + single_logout = true; + use_pkce = true; + auto_provision = true; + soft_auto_provision = true; + disable_account_creation = false; + }; }; config = { @@ -115,15 +201,159 @@ dbuser = "nextcloud"; dbname = "nextcloud"; dbpassFile = db-pass-filepath; + # TODO review whether admin user is needed at all - admin group works adminpassFile = admin-pass-filepath; adminuser = "admin"; }; - - enableImagemagick = cfg.enableImagemagick; }; services.nginx.virtualHosts.${hostName} = { useACMEHost = sp.domain; forceSSL = true; + #locations."/".extraConfig = lib.mkIf is-auth-enabled '' + # # FIXME does not work + # rewrite ^/login$ /apps/user_oidc/login/1 last; + #''; + # show an error instead of a blank page on Nextcloud PHP/FastCGI error + locations."~ \\.php(?:$|/)".extraConfig = '' + error_page 500 502 503 504 ${pkgs.nginx}/html/50x.html; + ''; }; - }; + } + # the following part is active only when "auth" module is enabled + (lib.mkIf is-auth-enabled { + systemd.services.nextcloud-setup = { + path = [ pkgs.jq ]; + script = '' + set -o errexit + set -o nounset + ${lib.strings.optionalString cfg.debug "set -o xtrace"} + + ${occ} app:install user_ldap || : + ${occ} app:enable user_ldap + + # The following code tries to match an existing config or creates a new one. + # The criteria for matching is the ldapHost value. + + # remove broken link after previous nextcloud (un)installation + [[ ! -f "${override-config-fp}" && -L "${override-config-fp}" ]] && \ + rm -v "${override-config-fp}" + + ALL_CONFIG="$(${occ} ldap:show-config --output=json)" + + MATCHING_CONFIG_IDs="$(jq '[to_entries[] | select(.value.ldapHost=="${ldap_scheme_and_host}") | .key]' <<<"$ALL_CONFIG")" + if [[ $(jq 'length' <<<"$MATCHING_CONFIG_IDs") > 0 ]]; then + CONFIG_ID="$(jq --raw-output '.[0]' <<<"$MATCHING_CONFIG_IDs")" + else + CONFIG_ID="$(${occ} ldap:create-empty-config --only-print-prefix)" + fi + + echo "Using configId $CONFIG_ID" + + # The following CLI commands follow + # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md#nextcloud-config--the-cli-way + + # StartTLS is not supported in Kanidm due to security risks, whereas + # user_ldap doesn't support SASL. Importing certificate doesn't + # help: + # ${occ} security:certificates:import "${config.security.acme.certs.${domain}.directory}/cert.pem" + ${occ} ldap:set-config "$CONFIG_ID" 'turnOffCertCheck' '1' + + ${occ} ldap:set-config "$CONFIG_ID" 'ldapHost' '${ldap_scheme_and_host}' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapPort' '${toString auth-passthru.ldap-port}' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapAgentName' 'dn=token' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapAgentPassword' "$(<${serviceAccountTokenFP})" + ${occ} ldap:set-config "$CONFIG_ID" 'ldapBase' '${auth-passthru.ldap-base-dn}' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapBaseGroups' '${auth-passthru.ldap-base-dn}' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapBaseUsers' '${auth-passthru.ldap-base-dn}' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapEmailAttribute' 'mail' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapGroupFilter' \ + '(&(class=group)(${wildcardGroup})' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapGroupFilterGroups' \ + '(&(class=group)(${wildcardGroup}))' + # ${occ} ldap:set-config "$CONFIG_ID" 'ldapGroupFilterObjectclass' \ + # 'groupOfUniqueNames' + # ${occ} ldap:set-config "$CONFIG_ID" 'ldapGroupMemberAssocAttr' \ + # 'uniqueMember' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapLoginFilter' \ + '(&(class=person)(memberof=${usersGroup})(uid=%uid))' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapLoginFilterAttributes' \ + 'uid' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapUserDisplayName' \ + 'displayname' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapUserFilter' \ + '(&(class=person)(memberof=${usersGroup})(name=%s))' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapUserFilterMode' \ + '1' + ${occ} ldap:set-config "$CONFIG_ID" 'ldapUserFilterObjectclass' \ + 'person' + + ${occ} ldap:test-config -- "$CONFIG_ID" + + # delete all configs except "$CONFIG_ID" + for configid in $(jq --raw-output "keys[] | select(. != \"$CONFIG_ID\")" <<<"$ALL_CONFIG"); do + echo "Deactivating $configid" + ${occ} ldap:set-config "$configid" 'ldapConfigurationActive' '0' + echo "Deactivated $configid" + echo "Deleting $configid" + ${occ} ldap:delete-config "$configid" + echo "Deleted $configid" + done + + ${occ} ldap:set-config "$CONFIG_ID" 'ldapConfigurationActive' '1' + + ############################################################################ + # OIDC app + ############################################################################ + ${occ} app:install user_oidc || : + ${occ} app:enable user_oidc + + ${occ} user_oidc:provider ${auth-passthru.oauth2-provider-name} \ + --clientid="${oauthClientID}" \ + --clientsecret="$(<${oauthClientSecretFP})" \ + --discoveryuri="${auth-passthru.oauth2-discovery-url "nextcloud"}" \ + --unique-uid=0 \ + --scope="email openid profile" \ + --mapping-uid=preferred_username \ + --no-interaction \ + --mapping-groups=groups \ + --group-provisioning=1 \ + -vvv + + '' + lib.optionalString deleteNextcloudAdmin '' + if [[ ! -f /var/lib/nextcloud/.admin-user-deleted ]]; then + ${occ} user:delete admin + touch /var/lib/nextcloud/.admin-user-deleted + fi + ''; + }; + selfprivacy.auth.clients."${oauthClientID}" = { + inherit adminsGroup usersGroup; + imageFile = ./icon.svg; + displayName = "Nextcloud"; + subdomain = cfg.subdomain; + isTokenNeeded = true; + originUrl = "https://${cfg.subdomain}.${domain}/apps/user_oidc/code"; + originLanding = + "https://${cfg.subdomain}.${domain}/apps/user_oidc/login/1"; + useShortPreferredUsername = true; + clientSystemdUnits = + [ "nextcloud-setup.service" "phpfpm-nextcloud.service" ]; + enablePkce = true; + linuxUserOfClient = linuxUserOfService; + linuxGroupOfClient = linuxGroupOfService; + scopeMaps.${usersGroup} = [ "email" "openid" "profile" ]; + claimMaps.groups = { + joinType = "array"; + valuesByGroup.${adminsGroup} = [ "admin" ]; + }; + }; + }) + (lib.mkIf (! is-auth-enabled) { + systemd.services.nextcloud-setup = { + script = '' + ${occ} app:disable user_oidc + ''; + }; + }) + ]); } diff --git a/sp-modules/ocserv/module.nix b/sp-modules/ocserv/module.nix index a332a17..b45fdd7 100644 --- a/sp-modules/ocserv/module.nix +++ b/sp-modules/ocserv/module.nix @@ -16,18 +16,6 @@ in type = "enable"; }; }; - subdomain = (lib.mkOption { - default = "vpn"; - type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; - description = "Subdomain"; - }) // { - meta = { - widget = "subdomain"; - type = "string"; - regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; - weight = 0; - }; - }; }; config = lib.mkIf cfg.enable { @@ -61,7 +49,7 @@ in idle-timeout=1200 mobile-idle-timeout=2400 - default-domain = ${cfg.subdomain}.${domain} + default-domain = ${domain} device = vpn0 @@ -75,19 +63,6 @@ in route = default ''; }; - services.nginx.virtualHosts."${cfg.subdomain}.${domain}" = { - useACMEHost = domain; - forceSSL = true; - extraConfig = '' - add_header Strict-Transport-Security $hsts_header; - #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always; - add_header 'Referrer-Policy' 'origin-when-cross-origin'; - add_header X-Frame-Options DENY; - add_header X-Content-Type-Options nosniff; - add_header X-XSS-Protection "1; mode=block"; - proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; - ''; - }; systemd = { services = { ocserv = { diff --git a/sp-modules/roundcube/config-paths-needed.json b/sp-modules/roundcube/config-paths-needed.json index a650a1e..17c45b0 100644 --- a/sp-modules/roundcube/config-paths-needed.json +++ b/sp-modules/roundcube/config-paths-needed.json @@ -1,5 +1,12 @@ [ - ["selfprivacy", "domain"], - ["selfprivacy", "modules", "roundcube"], - ["mailserver", "fqdn"] + [ "mailserver", "fqdn" ], + [ "selfprivacy", "domain" ], + [ "selfprivacy", "modules", "auth" ], + [ "selfprivacy", "modules", "roundcube" ], + [ "selfprivacy", "passthru", "auth", "auth-fqdn" ], + [ "selfprivacy", "passthru", "auth", "mkOAuth2ClientSecretFP" ], + [ "selfprivacy", "passthru", "auth", "oauth2-provider-name" ], + [ "selfprivacy", "passthru", "mailserver", "oauth-client-id" ], + [ "selfprivacy", "passthru", "mailserver", "oauth-client-secret-fp" ], + [ "selfprivacy", "sso", "enable" ] ] diff --git a/sp-modules/roundcube/flake.nix b/sp-modules/roundcube/flake.nix index 66cd9a2..d20f919 100644 --- a/sp-modules/roundcube/flake.nix +++ b/sp-modules/roundcube/flake.nix @@ -27,6 +27,10 @@ homepage = "https://roundcube.net/"; sourcePage = "https://github.com/roundcube/roundcubemail"; supportLevel = "normal"; + sso = { + userGroup = "sp.roundcube.users"; + adminGroup = "sp.roundcube.admins"; + }; }; }; } diff --git a/sp-modules/roundcube/module.nix b/sp-modules/roundcube/module.nix index 8067289..3e9babc 100644 --- a/sp-modules/roundcube/module.nix +++ b/sp-modules/roundcube/module.nix @@ -1,7 +1,29 @@ -{ config, lib, ... }: +{ config, lib, pkgs, ... }: let domain = config.selfprivacy.domain; cfg = config.selfprivacy.modules.roundcube; + is-auth-enabled = cfg.enableSso && config.selfprivacy.sso.enable; + auth-passthru = config.selfprivacy.passthru.auth; + auth-fqdn = auth-passthru.auth-fqdn; + + linuxUserOfService = "roundcube"; + linuxGroupOfService = "roundcube"; + + sp-module-name = "roundcube"; + + # SelfPrivacy uses SP Module ID to identify the group! + adminsGroup = "sp.${sp-module-name}.admins"; + usersGroup = "sp.${sp-module-name}.users"; + + oauth-donor = config.selfprivacy.passthru.mailserver; + oauthClientSecretFP = + auth-passthru.mkOAuth2ClientSecretFP linuxGroupOfService; + # copy client secret from mailserver + kanidmExecStartPreScriptRoot = pkgs.writeShellScript + "${sp-module-name}-kanidm-ExecStartPre-root-script.sh" + '' + install -v -m640 -o kanidm -g ${linuxGroupOfService} ${oauth-donor.oauth-client-secret-fp} ${oauthClientSecretFP} + ''; in { options.selfprivacy.modules.roundcube = { @@ -26,35 +48,97 @@ in weight = 0; }; }; + enableSso = (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Single Sign-On"; + }) // { + meta = { + type = "bool"; + weight = 1; + }; + }; }; - config = lib.mkIf cfg.enable { + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + assertions = [ + { + assertion = cfg.enableSso -> config.selfprivacy.sso.enable; + message = + "SSO cannot be enabled for Roundcube when SSO is disabled globally."; + } + ]; + services.roundcube = { + enable = true; + # this is the url of the vhost, not necessarily the same as the fqdn of + # the mailserver + hostName = "${cfg.subdomain}.${config.selfprivacy.domain}"; + extraConfig = '' + # starttls needed for authentication, so the fqdn required to match + # the certificate + $config['smtp_host'] = "tls://${config.mailserver.fqdn}"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + ''; + }; - services.roundcube = { - enable = true; - # this is the url of the vhost, not necessarily the same as the fqdn of - # the mailserver - hostName = "${cfg.subdomain}.${config.selfprivacy.domain}"; - extraConfig = '' - # starttls needed for authentication, so the fqdn required to match - # the certificate - $config['smtp_server'] = "tls://${config.mailserver.fqdn}"; - $config['smtp_user'] = "%u"; - $config['smtp_pass'] = "%p"; + services.nginx.virtualHosts."${cfg.subdomain}.${domain}" = { + forceSSL = true; + useACMEHost = domain; + enableACME = false; + }; + + systemd.slices.roundcube.description = "Roundcube service slice"; + # Roundcube depends on Dovecot and its OAuth2 client secret. + systemd.services.phpfpm-roundcube.after = [ "dovecot2.service" ]; + } + # the following part is active only when "auth" module is enabled + (lib.mkIf is-auth-enabled { + services.roundcube.extraConfig = lib.mkAfter '' + $config['oauth_provider'] = 'generic'; + $config['oauth_provider_name'] = '${auth-passthru.oauth2-provider-name}'; + $config['oauth_client_id'] = '${oauth-donor.oauth-client-id}'; + $config['oauth_client_secret'] = file_get_contents('${oauthClientSecretFP}'); + $config['oauth_auth_uri'] = 'https://${auth-fqdn}/ui/oauth2'; + $config['oauth_token_uri'] = 'https://${auth-fqdn}/oauth2/token'; + $config['oauth_identity_uri'] = 'https://${auth-fqdn}/oauth2/openid/${oauth-donor.oauth-client-id}/userinfo'; + $config['oauth_scope'] = 'email profile openid'; + $config['oauth_auth_parameters'] = []; + $config['oauth_identity_fields'] = ['email']; + $config['oauth_login_redirect'] = true; + $config['auto_create_user'] = true; ''; - }; - services.nginx.virtualHosts."${cfg.subdomain}.${domain}" = { - forceSSL = true; - useACMEHost = domain; - enableACME = false; - }; - systemd = { - services = { - phpfpm-roundcube.serviceConfig.Slice = lib.mkForce "roundcube.slice"; + systemd.services.roundcube = { + after = [ "dovecot2.service" ]; + requires = [ "dovecot2.service" ]; }; - slices.roundcube = { - description = "Roundcube service slice"; + systemd.services.kanidm.serviceConfig.ExecStartPre = lib.mkAfter [ + ("-+" + kanidmExecStartPreScriptRoot) + ]; + + selfprivacy.auth.clients."${oauth-donor.oauth-client-id}" = { + inherit adminsGroup usersGroup; + imageFile = ./icon.svg; + displayName = "Roundcube"; + subdomain = cfg.subdomain; + isTokenNeeded = false; + isMailserver = true; + originUrl = "https://${cfg.subdomain}.${domain}/index.php/login/oauth"; + originLanding = "https://${cfg.subdomain}.${domain}/"; + useShortPreferredUsername = false; + clientSystemdUnits = [ "dovecot2.service" "phpfpm-roundcube.service" ]; + enablePkce = false; + linuxUserOfClient = linuxUserOfService; + linuxGroupOfClient = linuxGroupOfService; + scopeMaps = { + "${usersGroup}" = [ + "email" + "openid" + "profile" + ]; + }; }; - }; - }; + }) + ]); } diff --git a/sp-modules/simple-nixos-mailserver/auth-dovecot.nix b/sp-modules/simple-nixos-mailserver/auth-dovecot.nix new file mode 100644 index 0000000..c0d6c20 --- /dev/null +++ b/sp-modules/simple-nixos-mailserver/auth-dovecot.nix @@ -0,0 +1,211 @@ +{ mailserver-service-account-name +, mailserver-service-account-token-name +, mailserver-service-account-token-fp +}: +{ config, lib, pkgs, ... }@nixos-args: +let + inherit (import ./common.nix nixos-args) + appendSetting + auth-passthru + cfg + domain + group + is-auth-enabled + ; + + runtime-folder = group; + keysPath = auth-passthru.keys-path; + + # create service account token, needed for LDAP + kanidmExecStartPostScript = pkgs.writeShellScript + "mailserver-kanidm-ExecStartPost-script.sh" + '' + export HOME=$RUNTIME_DIRECTORY/client_home + readonly KANIDM="${pkgs.kanidm}/bin/kanidm" + + # get Kanidm service account for mailserver + KANIDM_SERVICE_ACCOUNT="$($KANIDM service-account list --name idm_admin | grep -E "^name: ${mailserver-service-account-name}$")" + echo KANIDM_SERVICE_ACCOUNT: "$KANIDM_SERVICE_ACCOUNT" + if [ -n "$KANIDM_SERVICE_ACCOUNT" ] + then + echo "kanidm service account \"${mailserver-service-account-name}\" is found" + else + echo "kanidm service account \"${mailserver-service-account-name}\" is not found" + echo "creating new kanidm service account \"${mailserver-service-account-name}\"" + if $KANIDM service-account create --name idm_admin ${mailserver-service-account-name} ${mailserver-service-account-name} idm_admin + then + "kanidm service account \"${mailserver-service-account-name}\" created" + else + echo "error: cannot create kanidm service account \"${mailserver-service-account-name}\"" + exit 1 + fi + fi + + # add Kanidm service account to `idm_mail_servers` group + $KANIDM group add-members idm_mail_servers ${mailserver-service-account-name} + + # create a new read-only token for mailserver + if ! KANIDM_SERVICE_ACCOUNT_TOKEN_JSON="$($KANIDM service-account api-token generate --name idm_admin ${mailserver-service-account-name} ${mailserver-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 ! install --mode=640 \ + <(printf "%s" "$KANIDM_SERVICE_ACCOUNT_TOKEN") \ + ${mailserver-service-account-token-fp} + then + echo "error: cannot write token to \"${mailserver-service-account-token-fp}\"" + exit 1 + fi + ''; + + ldapConfFile = "/run/${runtime-folder}/dovecot-ldap.conf.ext"; + mkLdapSearchScope = scope: ( + if scope == "sub" then "subtree" + else if scope == "one" then "onelevel" + else scope + ); + 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 = appendSetting { + name = "ldap-conf-file"; + file = dovecot-ldap-config; + prefix = ''dnpass = "''; + suffix = ''"''; + passwordFile = config.mailserver.ldap.bind.passwordFile; + destination = ldapConfFile; + }; + oauth-client-id = "mailserver"; + oauth-client-secret-fp = + "${keysPath}/${group}/kanidm-oauth-client-secret"; + oauth-secret-ExecStartPreScript = pkgs.writeShellScript + "${oauth-client-id}-kanidm-ExecStartPre-script.sh" '' + set -o xtrace + [ -f "${oauth-client-secret-fp}" ] || \ + "${lib.getExe pkgs.openssl}" rand -base64 32 | tr "\n:@/+=" "012345" > "${oauth-client-secret-fp}" + ''; + dovecot-oauth2-conf-fp = "/run/${runtime-folder}/dovecot-oauth2.conf.ext"; + write-dovecot-oauth2-conf = appendSetting { + name = "oauth2-conf-file"; + file = builtins.toFile "dovecot-oauth2.conf.ext.template" '' + introspection_mode = post + username_attribute = username + scope = email profile openid + tls_ca_cert_file = /etc/ssl/certs/ca-certificates.crt + active_attribute = active + active_value = true + openid_configuration_url = ${auth-passthru.oauth2-discovery-url oauth-client-id} + debug = "no" + ''; + prefix = ''introspection_url = "'' + + (auth-passthru.oauth2-introspection-url-prefix oauth-client-id); + suffix = auth-passthru.oauth2-introspection-url-postfix + ''"''; + passwordFile = oauth-client-secret-fp; + destination = dovecot-oauth2-conf-fp; + }; +in +{ + # for dovecot2 to have access to get through ${keysPath} directory + users.groups.keys.members = [ group ]; + systemd.tmpfiles.settings."kanidm-secrets"."${keysPath}/${group}".d = { + user = "kanidm"; + inherit group; + mode = "2750"; + }; + + mailserver.ldap = { + # 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))"; + }; + + services.dovecot2.extraConfig = '' + auth_mechanisms = xoauth2 oauthbearer plain login + + passdb { + driver = oauth2 + mechanisms = xoauth2 oauthbearer + args = ${dovecot-oauth2-conf-fp} + } + + userdb { + driver = static + 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 { + mode = 0660 + user = postfix + group = postfix + } + } + service auth { + unix_listener auth-userdb { + mode = 0660 + user = ${config.services.dovecot2.user} + } + unix_listener dovecot-auth { + mode = 0660 + # Assuming the default Postfix user and group + user = postfix + group = postfix + } + } + + userdb { + driver = ldap + args = ${ldapConfFile} + default_fields = home=/var/vmail/${domain}/%u uid=${toString config.mailserver.vmailUID} gid=${toString config.mailserver.vmailUID} + } + ''; + services.dovecot2.enablePAM = false; + systemd.services.dovecot2 = { + preStart = setPwdInLdapConfFile + "\n" + write-dovecot-oauth2-conf + "\n"; + after = [ auth-passthru.oauth2-systemd-service ]; + requires = [ auth-passthru.oauth2-systemd-service ]; + serviceConfig.RuntimeDirectory = lib.mkForce [ runtime-folder ]; + }; + + systemd.services.kanidm.serviceConfig.ExecStartPre = lib.mkBefore [ + ("-" + oauth-secret-ExecStartPreScript) + ]; + systemd.services.kanidm.serviceConfig.ExecStartPost = lib.mkAfter [ + ("-" + kanidmExecStartPostScript) + ]; + + systemd.services.postfix.restartTriggers = [ + setPwdInLdapConfFile + write-dovecot-oauth2-conf + ]; + selfprivacy.passthru.mailserver = { + inherit oauth-client-id oauth-client-secret-fp; + }; +} diff --git a/sp-modules/simple-nixos-mailserver/auth-postfix.nix b/sp-modules/simple-nixos-mailserver/auth-postfix.nix new file mode 100644 index 0000000..1d1029e --- /dev/null +++ b/sp-modules/simple-nixos-mailserver/auth-postfix.nix @@ -0,0 +1,75 @@ +{ config, lib, pkgs, ... }@nixos-args: +let + inherit (import ./common.nix nixos-args) + appendSetting + auth-passthru + is-auth-enabled + ; + + cfg = config.mailserver; + + 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 = appendSetting { + 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 = appendSetting { + name = "ldap-virtual-mailbox-map"; + file = ldapVirtualMailboxMap; + prefix = "bind_pw = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapVirtualMailboxMapFile; + }; +in +{ + mailserver.ldap = { + postfix.mailAttribute = "mail"; + postfix.uidAttribute = "uid"; + }; + systemd.services.postfix-setup = { + preStart = '' + ${appendPwdInVirtualMailboxMap} + ${appendPwdInSenderLoginMap} + ''; + restartTriggers = + [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ]; + wants = [ auth-passthru.oauth2-systemd-service ]; + after = [ auth-passthru.oauth2-systemd-service ]; + }; + services.postfix = { + # the list should be merged with other options from nixos-mailserver + config.virtual_mailbox_maps = [ "ldap:${ldapVirtualMailboxMapFile}" ]; + inherit submissionOptions; + submissionsOptions = submissionOptions; + }; +} diff --git a/sp-modules/simple-nixos-mailserver/common.nix b/sp-modules/simple-nixos-mailserver/common.nix new file mode 100644 index 0000000..6e60dba --- /dev/null +++ b/sp-modules/simple-nixos-mailserver/common.nix @@ -0,0 +1,28 @@ +{ config, pkgs, ... }: +rec { + auth-passthru = config.selfprivacy.passthru.auth; + domain = config.selfprivacy.domain; + group = "dovecot2"; + is-auth-enabled = + config.selfprivacy.modules.simple-nixos-mailserver.enableSso + && config.selfprivacy.sso.enable; + + appendSetting = + { 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} + ''; +} diff --git a/sp-modules/simple-nixos-mailserver/config-paths-needed.json b/sp-modules/simple-nixos-mailserver/config-paths-needed.json index 9341829..e8ed883 100644 --- a/sp-modules/simple-nixos-mailserver/config-paths-needed.json +++ b/sp-modules/simple-nixos-mailserver/config-paths-needed.json @@ -3,14 +3,32 @@ [ "security", "acme", "certs" ], [ "selfprivacy", "domain" ], [ "selfprivacy", "hashedMasterPassword" ], + [ "selfprivacy", "modules", "auth", "enable" ], + [ "selfprivacy", "modules", "simple-nixos-mailserver" ], + [ "selfprivacy", "passthru", "auth", "admins-group" ], + [ "selfprivacy", "passthru", "auth", "full-users-group" ], + [ "selfprivacy", "passthru", "auth", "keys-path" ], + [ "selfprivacy", "passthru", "auth", "ldap-base-dn" ], + [ "selfprivacy", "passthru", "auth", "ldap-port" ], + [ "selfprivacy", "passthru", "auth", "oauth2-discovery-url" ], + [ "selfprivacy", "passthru", "auth", "oauth2-introspection-url-postfix" ], + [ "selfprivacy", "passthru", "auth", "oauth2-introspection-url-prefix" ], + [ "selfprivacy", "passthru", "auth", "oauth2-systemd-service" ], + [ "selfprivacy", "passthru", "roundcube", "oauth-client-id" ], + [ "selfprivacy", "passthru", "roundcube", "oauth-client-secret-fp" ], + [ "selfprivacy", "sso", "enable" ], [ "selfprivacy", "useBinds" ], [ "selfprivacy", "username" ], [ "selfprivacy", "users" ], - [ "services", "dovecot2" ], - [ "services", "opendkim" ], + [ "services", "dovecot2", "user" ], + [ "services", "opendkim", "configFile" ], + [ "services", "opendkim", "group" ], + [ "services", "opendkim", "socket" ], + [ "services", "opendkim", "user" ], [ "services", "postfix", "group" ], [ "services", "postfix", "user" ], - [ "services", "redis" ], - [ "services", "rspamd" ], - [ "selfprivacy", "modules", "simple-nixos-mailserver" ] + [ "services", "redis", "servers", "rspamd", "bind" ], + [ "services", "redis", "servers", "rspamd", "port" ], + [ "services", "redis", "servers", "rspamd", "requirePass" ], + [ "services", "rspamd" ] ] diff --git a/sp-modules/simple-nixos-mailserver/config.nix b/sp-modules/simple-nixos-mailserver/config.nix index 984289f..37d5e8a 100644 --- a/sp-modules/simple-nixos-mailserver/config.nix +++ b/sp-modules/simple-nixos-mailserver/config.nix @@ -1,64 +1,72 @@ -{ config, lib, ... }: +{ config, lib, pkgs, ... }@nixos-args: let sp = config.selfprivacy; -in -lib.mkIf sp.modules.simple-nixos-mailserver.enable -{ - fileSystems = lib.mkIf sp.useBinds - { - "/var/vmail" = { - device = - "/volumes/${sp.modules.simple-nixos-mailserver.location}/vmail"; - options = [ - "bind" - "x-systemd.required-by=postfix.service" - "x-systemd.before=postfix.service" - ]; - }; - "/var/sieve" = { - device = - "/volumes/${sp.modules.simple-nixos-mailserver.location}/sieve"; - options = [ - "bind" - "x-systemd.required-by=dovecot2.service" - "x-systemd.before=dovecot2.service" - ]; - }; - }; - users.users = { - virtualMail = { - isNormalUser = false; - }; + inherit (import ./common.nix { inherit config pkgs; }) + auth-passthru + domain + group + is-auth-enabled + ; + mailserver-service-account = { + mailserver-service-account-name = "sp.mailserver.service-account"; + mailserver-service-account-token-name = "mailserver-service-account-token"; + mailserver-service-account-token-fp = + "/run/keys/${group}/kanidm-service-account-token"; # FIXME sync with auth module }; - - users.groups.acmereceivers.members = [ "dovecot2" "postfix" "virtualMail" ]; - - mailserver = { - enable = true; - fqdn = sp.domain; - domains = [ sp.domain ]; - localDnsResolver = false; - - # A list of all login accounts. To create the password hashes, use - # mkpasswd -m sha-512 "super secret password" - loginAccounts = { - "${sp.username}@${sp.domain}" = { - hashedPassword = sp.hashedMasterPassword; - sieveScript = '' - require ["fileinto", "mailbox"]; - if header :contains "Chat-Version" "1.0" - { - fileinto :create "DeltaChat"; - stop; - } - ''; +in +lib.mkIf sp.modules.simple-nixos-mailserver.enable (lib.mkMerge [ + { + assertions = [ + { + assertion = + config.selfprivacy.modules.simple-nixos-mailserver.enableSso + -> config.selfprivacy.sso.enable; + message = + "SSO cannot be enabled for Mailserver when SSO is disabled globally."; + } + ]; + fileSystems = lib.mkIf sp.useBinds + { + "/var/vmail" = { + device = + "/volumes/${sp.modules.simple-nixos-mailserver.location}/vmail"; + options = [ + "bind" + "x-systemd.required-by=postfix.service" + "x-systemd.before=postfix.service" + ]; + }; + "/var/sieve" = { + device = + "/volumes/${sp.modules.simple-nixos-mailserver.location}/sieve"; + options = [ + "bind" + "x-systemd.required-by=dovecot2.service" + "x-systemd.before=dovecot2.service" + ]; + }; }; - } // builtins.listToAttrs (builtins.map - (user: { - name = "${user.username}@${sp.domain}"; - value = { - hashedPassword = user.hashedPassword; + + users.users = { + virtualMail = { + isNormalUser = false; + }; + }; + + users.groups.acmereceivers.members = [ "dovecot2" "postfix" "virtualMail" ]; + + mailserver = { + enable = true; + fqdn = sp.domain; + domains = [ sp.domain ]; + localDnsResolver = false; + + # A list of all login accounts. To create the password hashes, use + # mkpasswd -m sha-512 "super secret password" + loginAccounts = ({ + "${sp.username}@${sp.domain}" = { + hashedPassword = sp.hashedMasterPassword; sieveScript = '' require ["fileinto", "mailbox"]; if header :contains "Chat-Version" "1.0" @@ -68,41 +76,86 @@ lib.mkIf sp.modules.simple-nixos-mailserver.enable } ''; }; - }) - sp.users); + } // builtins.listToAttrs (builtins.map + (user: { + name = "${user.username}@${sp.domain}"; + value = { + hashedPassword = user.hashedPassword; + sieveScript = '' + require ["fileinto", "mailbox"]; + if header :contains "Chat-Version" "1.0" + { + fileinto :create "DeltaChat"; + stop; + } + ''; + }; + }) + sp.users)); - extraVirtualAliases = { - "admin@${sp.domain}" = "${sp.username}@${sp.domain}"; + extraVirtualAliases = { + "admin@${sp.domain}" = "${sp.username}@${sp.domain}"; + }; + + certificateScheme = "manual"; + certificateFile = "/var/lib/acme/root-${sp.domain}/fullchain.pem"; + keyFile = "/var/lib/acme/root-${sp.domain}/key.pem"; + + # Enable IMAP and POP3 + enableImap = true; + enableImapSsl = true; + enablePop3 = false; + enablePop3Ssl = false; + dkimSelector = "selector"; + + # Enable the ManageSieve protocol + enableManageSieve = true; + + virusScanning = false; + + mailDirectory = "/var/vmail"; }; - certificateScheme = "manual"; - certificateFile = "/var/lib/acme/root-${sp.domain}/fullchain.pem"; - keyFile = "/var/lib/acme/root-${sp.domain}/key.pem"; - - # Enable IMAP and POP3 - enableImap = true; - enableImapSsl = true; - enablePop3 = false; - enablePop3Ssl = false; - dkimSelector = "selector"; - - # Enable the ManageSieve protocol - enableManageSieve = true; - - virusScanning = false; - }; - - systemd = { - services = { - dovecot2.serviceConfig.Slice = "simple_nixos_mailserver.slice"; - postfix.serviceConfig.Slice = "simple_nixos_mailserver.slice"; - rspamd.serviceConfig.Slice = "simple_nixos_mailserver.slice"; - redis-rspamd.serviceConfig.Slice = "simple_nixos_mailserver.slice"; - opendkim.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + systemd = { + services = { + dovecot2.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + postfix.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + rspamd.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + redis-rspamd.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + opendkim.serviceConfig.Slice = "simple_nixos_mailserver.slice"; + }; + slices."simple_nixos_mailserver" = { + name = "simple_nixos_mailserver.slice"; + description = "Simple NixOS Mailserver service slice"; + }; }; - slices."simple_nixos_mailserver" = { - name = "simple_nixos_mailserver.slice"; - description = "Simple NixOS Mailserver service slice"; + } + # the following parts are active only when "auth" module is enabled + (lib.mkIf is-auth-enabled { + mailserver = { + extraVirtualAliases = lib.mkForce { }; + loginAccounts = lib.mkForce { }; + # LDAP is needed for Postfix to query Kanidm about email address ownership. + # LDAP is needed for Dovecot also. + ldap = { + # false; otherwise, simple-nixos-mailserver enables auth via 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 = + mailserver-service-account.mailserver-service-account-token-fp; + + # searchBase = "ou=persons," + ldap_base_dn; + searchBase = auth-passthru.ldap-base-dn; # TODO refine this + + # NOTE: 127.0.0.1 instead of localhost doesn't work (maybe because of TLS) + uris = [ "ldaps://localhost:${toString auth-passthru.ldap-port}" ]; + }; }; - }; -} + }) + (lib.mkIf is-auth-enabled + (import ./auth-dovecot.nix mailserver-service-account nixos-args)) + (lib.mkIf is-auth-enabled (import ./auth-postfix.nix nixos-args)) +]) diff --git a/sp-modules/simple-nixos-mailserver/options.nix b/sp-modules/simple-nixos-mailserver/options.nix index b8a5782..03ca031 100644 --- a/sp-modules/simple-nixos-mailserver/options.nix +++ b/sp-modules/simple-nixos-mailserver/options.nix @@ -18,5 +18,14 @@ type = "location"; }; }; + enableSso = (lib.mkOption { + default = true; + type = lib.types.bool; + description = "Enable SSO for mail server"; + }) // { + meta = { + type = "enable"; + }; + }; }; } diff --git a/sp-modules/vikunja/config-paths-needed.json b/sp-modules/vikunja/config-paths-needed.json new file mode 100644 index 0000000..0426aa4 --- /dev/null +++ b/sp-modules/vikunja/config-paths-needed.json @@ -0,0 +1,10 @@ +[ + [ "selfprivacy", "domain" ], + [ "selfprivacy", "modules", "auth", "enable" ], + [ "selfprivacy", "modules", "vikunja" ], + [ "selfprivacy", "passthru", "auth", "mkOAuth2ClientSecretFP" ], + [ "selfprivacy", "passthru", "auth", "oauth2-discovery-url" ], + [ "selfprivacy", "passthru", "auth", "oauth2-provider-name" ], + [ "selfprivacy", "sso", "enable" ], + [ "selfprivacy", "useBinds" ] +] diff --git a/sp-modules/vikunja/flake.lock b/sp-modules/vikunja/flake.lock new file mode 100644 index 0000000..c48dced --- /dev/null +++ b/sp-modules/vikunja/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs-24-11": { + "locked": { + "lastModified": 1744440957, + "narHash": "sha256-FHlSkNqFmPxPJvy+6fNLaNeWnF1lZSgqVCl/eWaJRc4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "26d499fc9f1d567283d5d56fcf367edd815dba1d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs-24-11": "nixpkgs-24-11" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/sp-modules/vikunja/flake.nix b/sp-modules/vikunja/flake.nix new file mode 100644 index 0000000..7558a21 --- /dev/null +++ b/sp-modules/vikunja/flake.nix @@ -0,0 +1,41 @@ +{ + description = "PoC SP module for Vikunja service"; + + inputs = { + nixpkgs-24-11.url = "github:NixOS/nixpkgs/nixos-24.11"; + }; + + outputs = {nixpkgs-24-11, ...}: { + nixosModules.default = import ./module.nix nixpkgs-24-11.legacyPackages.x86_64-linux; + configPathsNeeded = + builtins.fromJSON (builtins.readFile ./config-paths-needed.json); + meta = {lib, ...}: { + spModuleSchemaVersion = 1; + id = "vikunja"; + name = "Vikunja"; + description = "Vikunja, the fluffy, open-source, self-hostable to-do app."; + svgIcon = builtins.readFile ./icon.svg; + isMovable = true; + isRequired = false; + backupDescription = "Tasks and attachments."; + systemdServices = [ + "vikunja.service" + ]; + folders = [ + "/var/lib/vikunja" + ]; + postgreDatabases = [ + "vikunja" + ]; + license = [ + lib.licenses.agpl3Plus + ]; + homepage = "https://vikunja.io"; + sourcePage = "https://github.com/go-vikunja/vikunja"; + supportLevel = "normal"; + sso = { + userGroup = "sp.vikunja.users"; + }; + }; + }; +} diff --git a/sp-modules/vikunja/icon.svg b/sp-modules/vikunja/icon.svg new file mode 100644 index 0000000..b763ee8 --- /dev/null +++ b/sp-modules/vikunja/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/sp-modules/vikunja/load-client-secret-from-env.patch b/sp-modules/vikunja/load-client-secret-from-env.patch new file mode 100644 index 0000000..c8f4e17 --- /dev/null +++ b/sp-modules/vikunja/load-client-secret-from-env.patch @@ -0,0 +1,33 @@ +diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go +index 5e14c1b31..769dc96e8 100644 +--- a/pkg/modules/auth/openid/providers.go ++++ b/pkg/modules/auth/openid/providers.go +@@ -17,6 +17,7 @@ + package openid + + import ( ++ "os" + "regexp" + "strconv" + "strings" +@@ -129,12 +130,19 @@ func getProviderFromMap(pi map[string]interface{}) (provider *Provider, err erro + if scope == "" { + scope = "openid profile email" + } ++ ++ clientsecret, err := os.ReadFile(os.Getenv("SP_VIKUNJA_CLIENT_SECRET_PATH")) ++ ++ if err != nil { ++ panic(err) ++ } ++ + provider = &Provider{ + Name: pi["name"].(string), + Key: k, + AuthURL: pi["authurl"].(string), + OriginalAuthURL: pi["authurl"].(string), +- ClientSecret: pi["clientsecret"].(string), ++ ClientSecret: strings.TrimSuffix(string(clientsecret), "\n"), + LogoutURL: logoutURL, + Scope: scope, + } diff --git a/sp-modules/vikunja/module.nix b/sp-modules/vikunja/module.nix new file mode 100644 index 0000000..ee22a52 --- /dev/null +++ b/sp-modules/vikunja/module.nix @@ -0,0 +1,186 @@ +latestPkgs: { + config, + lib, + ... +}: let + sp = config.selfprivacy; + cfg = sp.modules.vikunja; + oauthClientID = "vikunja"; + auth-passthru = config.selfprivacy.passthru.auth; + oauth2-provider-name = auth-passthru.oauth2-provider-name; + oauthDiscoveryURL = auth-passthru.oauth2-discovery-url oauthClientID; + + # SelfPrivacy uses SP Module ID to identify the group! + usersGroup = "sp.vikunja.users"; + + oauthClientSecretFP = + auth-passthru.mkOAuth2ClientSecretFP oauthClientID; + + vikunjaPackage = latestPkgs.vikunja.overrideAttrs (old: { + doCheck = false; # Tests are slow. + patches = + (old.patches or []) + ++ [ + ./load-client-secret-from-env.patch + ]; + }); +in { + options.selfprivacy.modules.vikunja = { + enable = + (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Vikunja"; + }) + // { + meta = { + type = "enable"; + }; + }; + location = + (lib.mkOption { + type = lib.types.str; + description = "Vikunja location"; + }) + // { + meta = { + type = "location"; + }; + }; + subdomain = + (lib.mkOption { + default = "vikunja"; + type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Subdomain"; + }) + // { + meta = { + widget = "subdomain"; + type = "string"; + regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + weight = 0; + }; + }; + }; + + config = + lib.mkIf cfg.enable + { + assertions = [ + { + assertion = sp.sso.enable; + message = "Vikunja cannot be enabled when SSO is disabled."; + } + ]; + + fileSystems = lib.mkIf sp.useBinds { + "/var/lib/vikunja" = { + device = "/volumes/${cfg.location}/vikunja"; + options = ["bind"]; + }; + }; + + users = { + users.vikunja = { + isSystemUser = true; + group = "vikunja"; + }; + groups.vikunja = {}; + }; + + services.postgresql = { + ensureDatabases = ["vikunja"]; + ensureUsers = [ + { + name = "vikunja"; + ensureDBOwnership = true; + } + ]; + }; + + services.vikunja = { + enable = true; + package = vikunjaPackage; + frontendScheme = "https"; + frontendHostname = "${cfg.subdomain}.${sp.domain}"; + port = 4835; + + database = { + type = "postgres"; + host = "/run/postgresql"; + }; + + settings = { + service = { + enableregistration = false; + enabletotp = false; + enableuserdeletion = true; + }; + + auth = { + local.enabled = false; + openid = { + enabled = true; + providers = [ + { + name = oauth2-provider-name; + authurl = lib.strings.removeSuffix "/.well-known/openid-configuration" oauthDiscoveryURL; + clientid = oauthClientID; + clientsecret = ""; # There's patch for our Vikunja to make it load client secret from environment variable. + scope = "openid profile email"; + } + ]; + }; + }; + }; + }; + + services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = { + useACMEHost = sp.domain; + forceSSL = true; + extraConfig = '' + add_header Strict-Transport-Security $hsts_header; + #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always; + add_header 'Referrer-Policy' 'origin-when-cross-origin'; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + ''; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:4835"; + }; + }; + }; + + systemd = { + services.vikunja = { + unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/vikunja"; + serviceConfig = { + Slice = "vikunja.slice"; + LoadCredential = "oauth2-secret:${oauthClientSecretFP}"; + DynamicUser = lib.mkForce false; + User = "vikunja"; + Group = "vikunja"; + }; + environment.SP_VIKUNJA_CLIENT_SECRET_PATH = "%d/oauth2-secret"; + }; + slices.vikunja = { + description = "Vikunja service slice"; + }; + }; + + selfprivacy.auth.clients.${oauthClientID} = { + inherit usersGroup; + subdomain = cfg.subdomain; + isTokenNeeded = true; + originLanding = "https://${cfg.subdomain}.${sp.domain}/"; + originUrl = "https://${cfg.subdomain}.${sp.domain}/auth/openid/${lib.strings.toLower oauth2-provider-name}"; + clientSystemdUnits = ["vikunja.service"]; + enablePkce = false; + linuxUserOfClient = "vikunja"; + linuxGroupOfClient = "vikunja"; + }; + }; +} diff --git a/webserver/nginx.nix b/webserver/nginx.nix index 5955c1a..81fe3b3 100644 --- a/webserver/nginx.nix +++ b/webserver/nginx.nix @@ -55,6 +55,9 @@ in proxyPass = "http://127.0.0.1:5050"; proxyWebsockets = true; }; + "/internal" = { + return = 403; + }; }; }; };