{ description = "Nextcloud"; inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; outputs = inputs: { nixosModules.default = { config, lib, options, pkgs, ... }: let inherit (lib) concatStringsSep hasAttrByPath mkEnableOption mkIf mkMerge mkOption optional optionalAttrs types unique ; cfg = config.custom.nextcloud; nextcloudUrl = "https://${cfg.hostName}"; whiteboardUrl = if cfg.addons.whiteboard.externalUrl != null then cfg.addons.whiteboard.externalUrl else "https://${cfg.addons.whiteboard.hostName}"; euroofficeUrl = if cfg.addons.eurooffice.externalUrl != null then cfg.addons.eurooffice.externalUrl else "https://${cfg.addons.eurooffice.hostName}"; appSet = cfg.package.packages.apps; packagedAppNames = unique ( cfg.addons.apps ++ optional cfg.useAuthentikSSO "user_oidc" ++ optional cfg.addons.whiteboard.enable "whiteboard" ); packagedApps = lib.genAttrs packagedAppNames ( name: if builtins.hasAttr name appSet then builtins.getAttr name appSet else throw "custom.nextcloud: Nextcloud app '${name}' is not packaged for ${ cfg.package.name or "the selected Nextcloud package" }" ); euroofficeApp = pkgs.fetchNextcloudApp { url = cfg.addons.eurooffice.app.url; hash = cfg.addons.eurooffice.app.hash; license = cfg.addons.eurooffice.app.license; }; allExtraApps = packagedApps // optionalAttrs cfg.addons.eurooffice.enable { eurooffice = euroofficeApp; } // cfg.addons.extraApps; nginxTls = { forceSSL = cfg.nginx.forceSSL; enableACME = cfg.nginx.enableACME; useACMEHost = cfg.nginx.useACMEHost; }; publicProxyTarget = if cfg.anubis.enable then "http://${cfg.bindAddress}:${toString cfg.anubis.port}" else "http://${cfg.bindAddress}:${toString cfg.rawPort}"; authentikDiscoveryUrl = if cfg.authentik.discoveryUrl != null then cfg.authentik.discoveryUrl else "https://${cfg.authentik.hostName}/application/o/${cfg.authentik.applicationSlug}/.well-known/openid-configuration"; authentikBlueprint = '' version: 1 metadata: name: Nextcloud labels: blueprints.goauthentik.io/instantiate: "true" entries: - model: authentik_core.group identifiers: name: ${cfg.authentik.groupName} attrs: is_superuser: false - model: authentik_providers_oauth2.oauth2provider identifiers: name: ${cfg.authentik.providerName} attrs: name: ${cfg.authentik.providerName} client_type: confidential client_id: ${cfg.authentik.clientId} client_secret: !Env ${cfg.authentik.blueprint.secretEnvName} grant_types: - authorization_code - refresh_token authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] redirect_uris: - matching_mode: strict url: ${nextcloudUrl}/apps/user_oidc/code signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] - model: authentik_core.application identifiers: slug: ${cfg.authentik.applicationSlug} attrs: name: Nextcloud slug: ${cfg.authentik.applicationSlug} provider: !Find [authentik_providers_oauth2.oauth2provider, [name, ${cfg.authentik.providerName}]] - model: authentik_policies.policybinding identifiers: target: !Find [authentik_core.application, [slug, ${cfg.authentik.applicationSlug}]] group: !Find [authentik_core.group, [name, ${cfg.authentik.groupName}]] order: 0 attrs: enabled: true ''; whiteboardModuleAvailable = hasAttrByPath [ "services" "nextcloud-whiteboard-server" ] options; anubisModuleAvailable = hasAttrByPath [ "services" "anubis" "instances" ] options; whiteboardPackage = if cfg.addons.whiteboard.disableRecording then pkgs.nextcloud-whiteboard-server.overrideAttrs (old: { postPatch = (old.postPatch or "") + '' substituteInPlace websocket_server/SocketManager.js \ --replace-fail $'const support = await checkPuppeteerAvailability({ force: false })\n\t\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\tavailable: support.available,\n\t\t\t\treason: support.reason || null,\n\t\t\t})' $'if (process.env.DISABLE_RECORDING === "1") {\n\t\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\tavailable: false,\n\t\t\t\treason: null,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t\tconst support = await checkPuppeteerAvailability({ force: false })\n\t\tsocket.emit(\'recording-availability\', {\n\t\t\tavailable: support.available,\n\t\t\treason: support.reason || null,\n\t\t})' ''; }) else pkgs.nextcloud-whiteboard-server; whiteboardStart = pkgs.writeShellScript "nextcloud-whiteboard-with-secret" '' set -euo pipefail export JWT_SECRET_KEY="$(<"$CREDENTIALS_DIRECTORY/jwt")" exec ${lib.getExe whiteboardPackage} ''; configScript = pkgs.writeShellScript "nextcloud-stack-config" '' set -euo pipefail OCC=${lib.escapeShellArg "${config.services.nextcloud.occ}/bin/nextcloud-occ"} "$OCC" config:app:set files default_quota --value ${lib.escapeShellArg cfg.defaultQuota} ${lib.optionalString cfg.addons.whiteboard.enable '' jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")" "$OCC" config:app:set whiteboard collabBackendUrl --value=${lib.escapeShellArg "${whiteboardUrl}/"} "$OCC" config:app:set whiteboard jwt_secret_key --value="$jwt_secret" ''} ${lib.optionalString cfg.addons.eurooffice.enable '' jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")" "$OCC" config:app:set eurooffice StorageUrl --value=${lib.escapeShellArg "${nextcloudUrl}/"} "$OCC" config:app:set eurooffice DocumentServerUrl --value=${lib.escapeShellArg "${euroofficeUrl}/"} "$OCC" config:app:set eurooffice jwt_secret --value="$jwt_secret" "$OCC" config:app:set eurooffice jwt_header --value=${lib.escapeShellArg cfg.addons.eurooffice.jwtHeader} "$OCC" config:app:set eurooffice editFormats --value=${lib.escapeShellArg cfg.addons.eurooffice.editFormats} ''} ${lib.optionalString cfg.useAuthentikSSO '' oidc_secret="$(<"$CREDENTIALS_DIRECTORY/oidc")" "$OCC" user_oidc:provider ${lib.escapeShellArg cfg.authentik.providerName} \ --clientid=${lib.escapeShellArg cfg.authentik.clientId} \ --clientsecret="$oidc_secret" \ --discoveryuri=${lib.escapeShellArg authentikDiscoveryUrl} \ --unique-uid=${if cfg.authentik.uniqueUid then "1" else "0"} ''} ${concatStringsSep "\n" cfg.addons.occCommands} ''; in { options.custom.nextcloud = { enable = mkEnableOption "Nextcloud"; hostName = mkOption { type = types.str; description = "Public Nextcloud hostname."; example = "cloud.example.com"; }; bindAddress = mkOption { type = types.str; default = "127.0.0.1"; description = "Loopback address used by internal services."; }; rawPort = mkOption { type = types.port; default = 5003; description = "Port for the raw Nextcloud nginx vhost."; }; package = mkOption { type = types.package; default = pkgs.nextcloud34; defaultText = lib.literalExpression "pkgs.nextcloud34"; description = "Nextcloud package."; }; adminUser = mkOption { type = types.nullOr types.str; default = "admin"; description = "Initial admin username. Set to null to disable initial admin creation on Nextcloud 32+."; }; maxUploadSize = mkOption { type = types.str; default = "1G"; }; defaultQuota = mkOption { type = types.str; default = "10 GB"; }; appstoreEnable = mkOption { type = types.bool; default = false; }; settings = mkOption { type = types.attrs; default = { }; description = "Extra services.nextcloud.settings values."; }; useAuthentikSSO = mkOption { type = types.bool; default = false; description = "Configure the user_oidc app for Authentik."; }; secrets = { adminPasswordFile = mkOption { type = types.nullOr types.str; default = null; description = "Runtime file containing the initial Nextcloud admin password."; }; jwtFile = mkOption { type = types.nullOr types.str; default = null; description = "Runtime file containing the shared JWT secret for addons."; }; oidcClientSecretFile = mkOption { type = types.nullOr types.str; default = null; description = "Runtime file containing the Authentik OIDC client secret."; }; nextcloud = mkOption { type = types.attrsOf types.str; default = { }; description = "Extra secret config.php entries passed to services.nextcloud.secrets."; }; }; database = { type = mkOption { type = types.enum [ "mysql" "pgsql" "sqlite" ]; default = "mysql"; }; createLocally = mkOption { type = types.bool; default = true; }; name = mkOption { type = types.str; default = "nextcloud"; }; user = mkOption { type = types.str; default = "nextcloud"; }; host = mkOption { type = types.nullOr types.str; default = null; description = "Database host. Leave null when createLocally is true."; }; passwordFile = mkOption { type = types.nullOr types.str; default = null; }; }; nginx = { forceSSL = mkOption { type = types.bool; default = true; }; enableACME = mkOption { type = types.bool; default = false; }; useACMEHost = mkOption { type = types.nullOr types.str; default = null; example = "example.com"; }; extraPublicConfig = mkOption { type = types.lines; default = ""; }; }; anubis = { enable = mkOption { type = types.bool; default = false; }; port = mkOption { type = types.port; default = 7003; }; settings = mkOption { type = types.attrs; default = { }; }; }; authentik = { hostName = mkOption { type = types.str; default = "auth.example.com"; }; discoveryUrl = mkOption { type = types.nullOr types.str; default = null; }; clientId = mkOption { type = types.str; default = "nextcloud"; }; providerName = mkOption { type = types.str; default = "Authentik"; }; applicationSlug = mkOption { type = types.str; default = "nextcloud"; }; groupName = mkOption { type = types.str; default = "nextcloud-users"; }; uniqueUid = mkOption { type = types.bool; default = false; }; hideLoginForm = mkOption { type = types.bool; default = true; }; blueprint = { enable = mkOption { type = types.bool; default = false; description = "Generate an Authentik blueprint under /etc/nextcloud."; }; content = mkOption { type = types.str; readOnly = true; description = "Generated Authentik blueprint for use by an Authentik module."; }; secretEnvName = mkOption { type = types.str; default = "NEXTCLOUD_OIDC_SECRET"; }; }; }; addons = { apps = mkOption { type = types.listOf types.str; default = [ "mail" "deck" ]; description = "App names from the selected Nextcloud package app set."; }; extraApps = mkOption { type = types.attrsOf types.package; default = { }; description = "Additional app packages merged into services.nextcloud.extraApps."; }; occCommands = mkOption { type = types.listOf types.lines; default = [ ]; description = "Extra idempotent shell commands run after nextcloud-setup."; }; whiteboard = { enable = mkEnableOption "Nextcloud Whiteboard"; hostName = mkOption { type = types.str; default = "whiteboard.example.com"; }; externalUrl = mkOption { type = types.nullOr types.str; default = null; description = "External Whiteboard URL. Defaults to https://hostName."; }; port = mkOption { type = types.port; default = 5004; }; nginx.enable = mkOption { type = types.bool; default = true; }; disableRecording = mkOption { type = types.bool; default = false; description = "Apply the custom DISABLE_RECORDING patch from the original configuration."; }; settings = mkOption { type = types.attrsOf types.str; default = { }; }; }; eurooffice = { enable = mkEnableOption "EuroOffice document server integration"; hostName = mkOption { type = types.str; default = "office.example.com"; }; externalUrl = mkOption { type = types.nullOr types.str; default = null; description = "External EuroOffice URL. Defaults to https://hostName."; }; port = mkOption { type = types.port; default = 5005; }; nginx.enable = mkOption { type = types.bool; default = true; }; image = mkOption { type = types.str; default = "ghcr.io/euro-office/documentserver:latest"; }; jwtHeader = mkOption { type = types.str; default = "Authorization"; }; editFormats = mkOption { type = types.str; default = ''{"docm":true,"docx":true,"dotm":true,"dotx":true,"xlsm":true,"xlsx":true,"pptm":true,"pptx":true,"pdf":true}''; }; environment = mkOption { type = types.attrsOf types.str; default = { }; }; app = { url = mkOption { type = types.str; default = "https://github.com/nextcloud-releases/eurooffice/releases/download/v11.0.1/eurooffice-v11.0.1.tar.gz"; }; hash = mkOption { type = types.str; default = "sha256-HXpvyCNhlxAvrxSEu6/5u0mpg7TrTsaS2gii9mf74ns="; }; license = mkOption { type = types.str; default = "asl20"; }; }; }; }; }; config = mkIf cfg.enable (mkMerge [ { assertions = [ { assertion = cfg.adminUser == null || cfg.secrets.adminPasswordFile != null; message = "custom.nextcloud.secrets.adminPasswordFile is required when adminUser is not null."; } { assertion = !(cfg.addons.whiteboard.enable || cfg.addons.eurooffice.enable) || cfg.secrets.jwtFile != null; message = "custom.nextcloud.secrets.jwtFile is required by Whiteboard and EuroOffice."; } { assertion = !cfg.useAuthentikSSO || cfg.secrets.oidcClientSecretFile != null; message = "custom.nextcloud.secrets.oidcClientSecretFile is required when useAuthentikSSO is true."; } { assertion = !cfg.addons.whiteboard.enable || whiteboardModuleAvailable; message = "The selected nixpkgs does not provide services.nextcloud-whiteboard-server."; } { assertion = !cfg.anubis.enable || anubisModuleAvailable; message = "The selected nixpkgs does not provide services.anubis.instances."; } { assertion = !cfg.authentik.blueprint.enable || cfg.useAuthentikSSO; message = "authentik.blueprint.enable requires useAuthentikSSO = true."; } ]; services.nextcloud = { enable = true; package = cfg.package; hostName = "raw-" + cfg.hostName; appstoreEnable = cfg.appstoreEnable; maxUploadSize = cfg.maxUploadSize; configureRedis = true; database.createLocally = cfg.database.createLocally; config = { adminuser = cfg.adminUser; adminpassFile = cfg.secrets.adminPasswordFile; dbtype = cfg.database.type; dbname = cfg.database.name; dbuser = cfg.database.user; } // optionalAttrs (cfg.database.host != null) { dbhost = cfg.database.host; } // optionalAttrs (cfg.database.passwordFile != null) { dbpassFile = cfg.database.passwordFile; }; https = true; settings = mkMerge [ { trusted_domains = [ cfg.hostName ]; trusted_proxies = unique [ cfg.bindAddress "127.0.0.1" "::1" ]; overwritehost = cfg.hostName; overwriteprotocol = "https"; } (mkIf cfg.useAuthentikSSO { hide_login_form = cfg.authentik.hideLoginForm; }) cfg.settings ]; secrets = cfg.secrets.nextcloud; extraAppsEnable = true; extraApps = allExtraApps; }; services.nginx.virtualHosts.${"raw-" + cfg.hostName}.listen = lib.mkForce [ { addr = cfg.bindAddress; port = cfg.rawPort; } ]; services.nginx.virtualHosts.${cfg.hostName} = nginxTls // { extraConfig = cfg.nginx.extraPublicConfig; locations."/" = { proxyPass = publicProxyTarget; proxyWebsockets = true; }; }; systemd.services.nextcloud-stack-config = { description = "Declarative Nextcloud stack configuration"; after = [ "nextcloud-setup.service" ]; requires = [ "nextcloud-setup.service" ]; wantedBy = [ "multi-user.target" ]; restartTriggers = [ configScript ]; serviceConfig = { Type = "oneshot"; User = "nextcloud"; LoadCredential = optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}" ++ optional (cfg.secrets.oidcClientSecretFile != null) "oidc:${cfg.secrets.oidcClientSecretFile}" ++ optional (cfg.database.passwordFile != null) "dbpass:${cfg.database.passwordFile}" ++ lib.mapAttrsToList (name: path: "${name}:${path}") cfg.secrets.nextcloud; ExecStart = configScript; }; }; } (mkIf (cfg.anubis.enable && anubisModuleAvailable) { services.anubis.instances.nextcloud.settings = { BIND = "${cfg.bindAddress}:${toString cfg.anubis.port}"; TARGET = "http://${cfg.bindAddress}:${toString cfg.rawPort}"; } // cfg.anubis.settings; }) (mkIf (cfg.addons.whiteboard.enable && whiteboardModuleAvailable) { services.nextcloud-whiteboard-server = { enable = true; secrets = [ ]; settings = { NEXTCLOUD_URL = nextcloudUrl; HOST = cfg.bindAddress; PORT = toString cfg.addons.whiteboard.port; } // cfg.addons.whiteboard.settings; }; systemd.services.nextcloud-whiteboard-server.serviceConfig = { LoadCredential = optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}"; ExecStart = lib.mkForce whiteboardStart; }; }) (mkIf (cfg.addons.whiteboard.enable && cfg.addons.whiteboard.nginx.enable) { services.nginx.virtualHosts.${cfg.addons.whiteboard.hostName} = nginxTls // { locations."/" = { proxyPass = "http://${cfg.bindAddress}:${toString cfg.addons.whiteboard.port}"; proxyWebsockets = true; }; }; }) (mkIf (cfg.addons.whiteboard.enable && cfg.addons.whiteboard.disableRecording) { services.nextcloud-whiteboard-server.settings.DISABLE_RECORDING = "1"; }) (mkIf cfg.addons.eurooffice.enable { virtualisation.oci-containers.containers.nextcloud-eurooffice = { serviceName = "nextcloud-eurooffice"; image = cfg.addons.eurooffice.image; ports = [ "${cfg.bindAddress}:${toString cfg.addons.eurooffice.port}:80" ]; environmentFiles = [ "/run/nextcloud-eurooffice/eurooffice.env" ]; environment = { JWT_ENABLED = "true"; JWT_HEADER = cfg.addons.eurooffice.jwtHeader; ALLOW_PRIVATE_IP_ADDRESS = "true"; } // cfg.addons.eurooffice.environment; }; systemd.services.nextcloud-eurooffice = { serviceConfig = { LoadCredential = optional (cfg.secrets.jwtFile != null) "jwt:${cfg.secrets.jwtFile}"; RuntimeDirectory = "nextcloud-eurooffice"; RuntimeDirectoryMode = "0700"; }; preStart = '' set -euo pipefail jwt_secret="$(<"$CREDENTIALS_DIRECTORY/jwt")" umask 077 printf 'JWT_SECRET=%s\n' "$jwt_secret" > /run/nextcloud-eurooffice/eurooffice.env ''; }; }) (mkIf (cfg.addons.eurooffice.enable && cfg.addons.eurooffice.nginx.enable) { services.nginx.virtualHosts.${cfg.addons.eurooffice.hostName} = nginxTls // { locations."/" = { proxyPass = "http://${cfg.bindAddress}:${toString cfg.addons.eurooffice.port}"; proxyWebsockets = true; }; }; }) (mkIf cfg.authentik.blueprint.enable { custom.nextcloud.authentik.blueprint.content = authentikBlueprint; environment.etc."nextcloud/authentik-blueprint.yaml".text = authentikBlueprint; }) ]); }; }; }