Add flake

This commit is contained in:
Alois 2026-08-22 11:11:34 +02:00
commit 945846e0b5
No known key found for this signature in database
3 changed files with 829 additions and 1 deletions

756
flake.nix Normal file
View file

@ -0,0 +1,756 @@
{
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
setAttrByPath
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;
authentikTargetAvailable = hasAttrByPath cfg.authentik.blueprint.targetOption options;
whiteboardStart = pkgs.writeShellScript "nextcloud-whiteboard-with-secret" ''
set -euo pipefail
export JWT_SECRET_KEY="$(<"$CREDENTIALS_DIRECTORY/jwt")"
exec ${lib.getExe pkgs.nextcloud-whiteboard-server}
'';
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.";
};
autoAttach = mkOption {
type = types.bool;
default = true;
description = "Also attach the blueprint to targetOption when that option exists.";
};
targetOption = mkOption {
type = types.listOf types.str;
default = [
"methanium"
"authentik"
"blueprints"
"nextcloud"
];
};
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) {
nixpkgs.overlays = [
(final: prev: {
nextcloud-whiteboard-server = prev.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\t\tsocket.emit(\'recording-availability\', {\n\t\t\t\t\tavailable: false,\n\t\t\t\t\treason: null,\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst 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})'
'';
});
})
];
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 {
environment.etc."nextcloud/authentik-blueprint.yaml".text = authentikBlueprint;
})
(mkIf (
cfg.authentik.blueprint.enable && cfg.authentik.blueprint.autoAttach && authentikTargetAvailable
) (setAttrByPath cfg.authentik.blueprint.targetOption authentikBlueprint))
]);
};
};
}