diff options
Diffstat (limited to 'nixos/modules/services/web-apps')
76 files changed, 18251 insertions, 0 deletions
diff --git a/nixos/modules/services/web-apps/atlassian/confluence.nix b/nixos/modules/services/web-apps/atlassian/confluence.nix new file mode 100644 index 00000000000..2d809c17ff0 --- /dev/null +++ b/nixos/modules/services/web-apps/atlassian/confluence.nix @@ -0,0 +1,197 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.confluence; + + pkg = cfg.package.override (optionalAttrs cfg.sso.enable { + enableSSO = cfg.sso.enable; + crowdProperties = '' + application.name ${cfg.sso.applicationName} + application.password ${cfg.sso.applicationPassword} + application.login.url ${cfg.sso.crowd}/console/ + + crowd.server.url ${cfg.sso.crowd}/services/ + crowd.base.url ${cfg.sso.crowd}/ + + session.isauthenticated session.isauthenticated + session.tokenkey session.tokenkey + session.validationinterval ${toString cfg.sso.validationInterval} + session.lastvalidation session.lastvalidation + ''; + }); + +in + +{ + options = { + services.confluence = { + enable = mkEnableOption "Atlassian Confluence service"; + + user = mkOption { + type = types.str; + default = "confluence"; + description = "User which runs confluence."; + }; + + group = mkOption { + type = types.str; + default = "confluence"; + description = "Group which runs confluence."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/confluence"; + description = "Home directory of the confluence instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8090; + description = "Port to listen on."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" "-Dconfluence.disable.peopledirectory.all=true" ]; + description = "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption "proxy support"; + + name = mkOption { + type = types.str; + example = "confluence.example.com"; + description = "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.int; + default = 443; + example = 80; + description = "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = "Protocol used at the proxy."; + }; + }; + + sso = { + enable = mkEnableOption "SSO with Atlassian Crowd"; + + crowd = mkOption { + type = types.str; + example = "http://localhost:8095/crowd"; + description = "Crowd Base URL without trailing slash"; + }; + + applicationName = mkOption { + type = types.str; + example = "jira"; + description = "Exact name of this Confluence instance in Crowd"; + }; + + applicationPassword = mkOption { + type = types.str; + description = "Application password of this Confluence instance in Crowd"; + }; + + validationInterval = mkOption { + type = types.int; + default = 2; + example = 0; + description = '' + Set to 0, if you want authentication checks to occur on each + request. Otherwise set to the number of minutes between request + to validate if the user is logged in or out of the Crowd SSO + server. Setting this value to 1 or higher will increase the + performance of Crowd's integration. + ''; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-confluence; + defaultText = literalExpression "pkgs.atlassian-confluence"; + description = "Atlassian Confluence package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} - - -" + "d /run/confluence - - - - -" + + "L+ /run/confluence/home - - - - ${cfg.home}" + "L+ /run/confluence/logs - - - - ${cfg.home}/logs" + "L+ /run/confluence/temp - - - - ${cfg.home}/temp" + "L+ /run/confluence/work - - - - ${cfg.home}/work" + "L+ /run/confluence/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.confluence = { + description = "Atlassian Confluence"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage pkgs.bash ]; + + environment = { + CONF_USER = cfg.user; + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + }; + + preStart = '' + mkdir -p ${cfg.home}/{logs,work,temp,deploy} + + sed -e 's,port="8090",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,protocol="org.apache.coyote.http11.Http11NioProtocol",protocol="org.apache.coyote.http11.Http11NioProtocol" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}",' \ + '') + '' + ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + ExecStart = "${pkg}/bin/start-confluence.sh -fg"; + ExecStop = "${pkg}/bin/stop-confluence.sh"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/atlassian/crowd.nix b/nixos/modules/services/web-apps/atlassian/crowd.nix new file mode 100644 index 00000000000..a8b2482d5a9 --- /dev/null +++ b/nixos/modules/services/web-apps/atlassian/crowd.nix @@ -0,0 +1,164 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.crowd; + + pkg = cfg.package.override { + home = cfg.home; + port = cfg.listenPort; + openidPassword = cfg.openidPassword; + } // (optionalAttrs cfg.proxy.enable { + proxyUrl = "${cfg.proxy.scheme}://${cfg.proxy.name}:${toString cfg.proxy.port}"; + }); + +in + +{ + options = { + services.crowd = { + enable = mkEnableOption "Atlassian Crowd service"; + + user = mkOption { + type = types.str; + default = "crowd"; + description = "User which runs Crowd."; + }; + + group = mkOption { + type = types.str; + default = "crowd"; + description = "Group which runs Crowd."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/crowd"; + description = "Home directory of the Crowd instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8092; + description = "Port to listen on."; + }; + + openidPassword = mkOption { + type = types.str; + description = "Application password for OpenID server."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" ]; + description = "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption "reverse proxy support"; + + name = mkOption { + type = types.str; + example = "crowd.example.com"; + description = "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.int; + default = 443; + example = 80; + description = "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = "Protocol used at the proxy."; + }; + + secure = mkOption { + type = types.bool; + default = true; + description = "Whether the connections to the proxy should be considered secure."; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-crowd; + defaultText = literalExpression "pkgs.atlassian-crowd"; + description = "Atlassian Crowd package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} ${cfg.group} - -" + "d /run/atlassian-crowd - - - - -" + + "L+ /run/atlassian-crowd/database - - - - ${cfg.home}/database" + "L+ /run/atlassian-crowd/logs - - - - ${cfg.home}/logs" + "L+ /run/atlassian-crowd/work - - - - ${cfg.home}/work" + "L+ /run/atlassian-crowd/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.atlassian-crowd = { + description = "Atlassian Crowd"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage ]; + + environment = { + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + CATALINA_TMPDIR = "/tmp"; + }; + + preStart = '' + rm -rf ${cfg.home}/work + mkdir -p ${cfg.home}/{logs,database,work} + + sed -e 's,port="8095",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,compression="on",compression="off" protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${boolToString cfg.proxy.secure}",' \ + '') + '' + ${pkg}/apache-tomcat/conf/server.xml.dist > ${cfg.home}/server.xml + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + ExecStart = "${pkg}/start_crowd.sh -fg"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/atlassian/jira.nix b/nixos/modules/services/web-apps/atlassian/jira.nix new file mode 100644 index 00000000000..d7a26838d6f --- /dev/null +++ b/nixos/modules/services/web-apps/atlassian/jira.nix @@ -0,0 +1,204 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.jira; + + pkg = cfg.package.override (optionalAttrs cfg.sso.enable { + enableSSO = cfg.sso.enable; + crowdProperties = '' + application.name ${cfg.sso.applicationName} + application.password ${cfg.sso.applicationPassword} + application.login.url ${cfg.sso.crowd}/console/ + + crowd.server.url ${cfg.sso.crowd}/services/ + crowd.base.url ${cfg.sso.crowd}/ + + session.isauthenticated session.isauthenticated + session.tokenkey session.tokenkey + session.validationinterval ${toString cfg.sso.validationInterval} + session.lastvalidation session.lastvalidation + ''; + }); + +in + +{ + options = { + services.jira = { + enable = mkEnableOption "Atlassian JIRA service"; + + user = mkOption { + type = types.str; + default = "jira"; + description = "User which runs JIRA."; + }; + + group = mkOption { + type = types.str; + default = "jira"; + description = "Group which runs JIRA."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/jira"; + description = "Home directory of the JIRA instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8091; + description = "Port to listen on."; + }; + + catalinaOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-Xms1024m" "-Xmx2048m" ]; + description = "Java options to pass to catalina/tomcat."; + }; + + proxy = { + enable = mkEnableOption "reverse proxy support"; + + name = mkOption { + type = types.str; + example = "jira.example.com"; + description = "Virtual hostname at the proxy"; + }; + + port = mkOption { + type = types.int; + default = 443; + example = 80; + description = "Port used at the proxy"; + }; + + scheme = mkOption { + type = types.str; + default = "https"; + example = "http"; + description = "Protocol used at the proxy."; + }; + + secure = mkOption { + type = types.bool; + default = true; + description = "Whether the connections to the proxy should be considered secure."; + }; + }; + + sso = { + enable = mkEnableOption "SSO with Atlassian Crowd"; + + crowd = mkOption { + type = types.str; + example = "http://localhost:8095/crowd"; + description = "Crowd Base URL without trailing slash"; + }; + + applicationName = mkOption { + type = types.str; + example = "jira"; + description = "Exact name of this JIRA instance in Crowd"; + }; + + applicationPassword = mkOption { + type = types.str; + description = "Application password of this JIRA instance in Crowd"; + }; + + validationInterval = mkOption { + type = types.int; + default = 2; + example = 0; + description = '' + Set to 0, if you want authentication checks to occur on each + request. Otherwise set to the number of minutes between request + to validate if the user is logged in or out of the Crowd SSO + server. Setting this value to 1 or higher will increase the + performance of Crowd's integration. + ''; + }; + }; + + package = mkOption { + type = types.package; + default = pkgs.atlassian-jira; + defaultText = literalExpression "pkgs.atlassian-jira"; + description = "Atlassian JIRA package to use."; + }; + + jrePackage = mkOption { + type = types.package; + default = pkgs.oraclejre8; + defaultText = literalExpression "pkgs.oraclejre8"; + description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152)."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.rules = [ + "d '${cfg.home}' - ${cfg.user} - - -" + "d /run/atlassian-jira - - - - -" + + "L+ /run/atlassian-jira/home - - - - ${cfg.home}" + "L+ /run/atlassian-jira/logs - - - - ${cfg.home}/logs" + "L+ /run/atlassian-jira/work - - - - ${cfg.home}/work" + "L+ /run/atlassian-jira/temp - - - - ${cfg.home}/temp" + "L+ /run/atlassian-jira/server.xml - - - - ${cfg.home}/server.xml" + ]; + + systemd.services.atlassian-jira = { + description = "Atlassian JIRA"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + path = [ cfg.jrePackage pkgs.bash ]; + + environment = { + JIRA_USER = cfg.user; + JIRA_HOME = cfg.home; + JAVA_HOME = "${cfg.jrePackage}"; + CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions; + }; + + preStart = '' + mkdir -p ${cfg.home}/{logs,work,temp,deploy} + + sed -e 's,port="8080",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \ + '' + (lib.optionalString cfg.proxy.enable '' + -e 's,protocol="HTTP/1.1",protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${toString cfg.proxy.secure}",' \ + '') + '' + ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + ExecStart = "${pkg}/bin/start-jira.sh -fg"; + ExecStop = "${pkg}/bin/stop-jira.sh"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/baget.nix b/nixos/modules/services/web-apps/baget.nix new file mode 100644 index 00000000000..3007dd4fbb2 --- /dev/null +++ b/nixos/modules/services/web-apps/baget.nix @@ -0,0 +1,170 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.baget; + + defaultConfig = { + "PackageDeletionBehavior" = "Unlist"; + "AllowPackageOverwrites" = false; + + "Database" = { + "Type" = "Sqlite"; + "ConnectionString" = "Data Source=baget.db"; + }; + + "Storage" = { + "Type" = "FileSystem"; + "Path" = ""; + }; + + "Search" = { + "Type" = "Database"; + }; + + "Mirror" = { + "Enabled" = false; + "PackageSource" = "https://api.nuget.org/v3/index.json"; + }; + + "Logging" = { + "IncludeScopes" = false; + "Debug" = { + "LogLevel" = { + "Default" = "Warning"; + }; + }; + "Console" = { + "LogLevel" = { + "Microsoft.Hosting.Lifetime" = "Information"; + "Default" = "Warning"; + }; + }; + }; + }; + + configAttrs = recursiveUpdate defaultConfig cfg.extraConfig; + + configFormat = pkgs.formats.json {}; + configFile = configFormat.generate "appsettings.json" configAttrs; + +in +{ + options.services.baget = { + enable = mkEnableOption "BaGet NuGet-compatible server"; + + apiKeyFile = mkOption { + type = types.path; + example = "/root/baget.key"; + description = '' + Private API key for BaGet. + ''; + }; + + extraConfig = mkOption { + type = configFormat.type; + default = {}; + example = { + "Database" = { + "Type" = "PostgreSql"; + "ConnectionString" = "Server=/run/postgresql;Port=5432;"; + }; + }; + defaultText = literalExpression '' + { + "PackageDeletionBehavior" = "Unlist"; + "AllowPackageOverwrites" = false; + + "Database" = { + "Type" = "Sqlite"; + "ConnectionString" = "Data Source=baget.db"; + }; + + "Storage" = { + "Type" = "FileSystem"; + "Path" = ""; + }; + + "Search" = { + "Type" = "Database"; + }; + + "Mirror" = { + "Enabled" = false; + "PackageSource" = "https://api.nuget.org/v3/index.json"; + }; + + "Logging" = { + "IncludeScopes" = false; + "Debug" = { + "LogLevel" = { + "Default" = "Warning"; + }; + }; + "Console" = { + "LogLevel" = { + "Microsoft.Hosting.Lifetime" = "Information"; + "Default" = "Warning"; + }; + }; + }; + } + ''; + description = '' + Extra configuration options for BaGet. Refer to <link xlink:href="https://loic-sharma.github.io/BaGet/configuration/"/> for details. + Default value is merged with values from here. + ''; + }; + }; + + # implementation + + config = mkIf cfg.enable { + + systemd.services.baget = { + description = "BaGet server"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network.target" "network-online.target" ]; + path = [ pkgs.jq ]; + serviceConfig = { + WorkingDirectory = "/var/lib/baget"; + DynamicUser = true; + StateDirectory = "baget"; + StateDirectoryMode = "0700"; + LoadCredential = "api_key:${cfg.apiKeyFile}"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + PrivateMounts = true; + ProtectHome = true; + ProtectClock = true; + ProtectProc = "noaccess"; + ProcSubset = "pid"; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectHostname = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + RestrictNamespaces = true; + LockPersonality = true; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + SystemCallFilter = [ "@system-service" "~@privileged" ]; + }; + script = '' + jq --slurpfile apiKeys <(jq -R . "$CREDENTIALS_DIRECTORY/api_key") '.ApiKey = $apiKeys[0]' ${configFile} > appsettings.json + ln -snf ${pkgs.baget}/lib/BaGet/wwwroot wwwroot + exec ${pkgs.baget}/bin/BaGet + ''; + }; + + }; +} diff --git a/nixos/modules/services/web-apps/bookstack.nix b/nixos/modules/services/web-apps/bookstack.nix new file mode 100644 index 00000000000..64a2767fab6 --- /dev/null +++ b/nixos/modules/services/web-apps/bookstack.nix @@ -0,0 +1,449 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.bookstack; + bookstack = pkgs.bookstack.override { + dataDir = cfg.dataDir; + }; + db = cfg.database; + mail = cfg.mail; + + user = cfg.user; + group = cfg.group; + + # shell script for local administration + artisan = pkgs.writeScriptBin "bookstack" '' + #! ${pkgs.runtimeShell} + cd ${bookstack} + sudo=exec + if [[ "$USER" != ${user} ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${user}' + fi + $sudo ${pkgs.php}/bin/php artisan $* + ''; + + tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME; + +in { + imports = [ + (mkRemovedOptionModule [ "services" "bookstack" "extraConfig" ] "Use services.bookstack.config instead.") + (mkRemovedOptionModule [ "services" "bookstack" "cacheDir" ] "The cache directory is now handled automatically.") + ]; + + options.services.bookstack = { + + enable = mkEnableOption "BookStack"; + + user = mkOption { + default = "bookstack"; + description = "User bookstack runs as."; + type = types.str; + }; + + group = mkOption { + default = "bookstack"; + description = "Group bookstack runs as."; + type = types.str; + }; + + appKeyFile = mkOption { + description = '' + A file containing the Laravel APP_KEY - a 32 character long, + base64 encoded key used for encryption where needed. Can be + generated with <code>head -c 32 /dev/urandom | base64</code>. + ''; + example = "/run/keys/bookstack-appkey"; + type = types.path; + }; + + hostname = lib.mkOption { + type = lib.types.str; + default = if config.networking.domain != null then + config.networking.fqdn + else + config.networking.hostName; + defaultText = lib.literalExpression "config.networking.fqdn"; + example = "bookstack.example.com"; + description = '' + The hostname to serve BookStack on. + ''; + }; + + appURL = mkOption { + description = '' + The root URL that you want to host BookStack on. All URLs in BookStack will be generated using this value. + If you change this in the future you may need to run a command to update stored URLs in the database. Command example: <code>php artisan bookstack:update-url https://old.example.com https://new.example.com</code> + ''; + default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; + defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}''; + example = "https://example.com"; + type = types.str; + }; + + dataDir = mkOption { + description = "BookStack data directory"; + default = "/var/lib/bookstack"; + type = types.path; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = "Database host port."; + }; + name = mkOption { + type = types.str; + default = "bookstack"; + description = "Database name."; + }; + user = mkOption { + type = types.str; + default = user; + defaultText = literalExpression "user"; + description = "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/bookstack-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = false; + description = "Create the database and database user locally."; + }; + }; + + mail = { + driver = mkOption { + type = types.enum [ "smtp" "sendmail" ]; + default = "smtp"; + description = "Mail driver to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = "Mail host address."; + }; + port = mkOption { + type = types.port; + default = 1025; + description = "Mail host port."; + }; + fromName = mkOption { + type = types.str; + default = "BookStack"; + description = "Mail \"from\" name."; + }; + from = mkOption { + type = types.str; + default = "mail@bookstackapp.com"; + description = "Mail \"from\" email."; + }; + user = mkOption { + type = with types; nullOr str; + default = null; + example = "bookstack"; + description = "Mail username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/bookstack-mailpassword"; + description = '' + A file containing the password corresponding to + <option>mail.user</option>. + ''; + }; + encryption = mkOption { + type = with types; nullOr (enum [ "tls" ]); + default = null; + description = "SMTP encryption mechanism to use."; + }; + }; + + maxUploadSize = mkOption { + type = types.str; + default = "18M"; + example = "1G"; + description = "The maximum size for uploads (e.g. images)."; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the bookstack PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + nginx = mkOption { + type = types.submodule ( + recursiveUpdate + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {} + ); + default = {}; + example = literalExpression '' + { + serverAliases = [ + "bookstack.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = '' + With this option, you can customize the nginx virtualHost settings. + ''; + }; + + config = mkOption { + type = with types; + attrsOf + (nullOr + (either + (oneOf [ + bool + int + port + path + str + ]) + (submodule { + options = { + _secret = mkOption { + type = nullOr str; + description = '' + The path to a file containing the value the + option should be set to in the final + configuration file. + ''; + }; + }; + }))); + default = {}; + example = literalExpression '' + { + ALLOWED_IFRAME_HOSTS = "https://example.com"; + WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf"; + AUTH_METHOD = "oidc"; + OIDC_NAME = "MyLogin"; + OIDC_DISPLAY_NAME_CLAIMS = "name"; + OIDC_CLIENT_ID = "bookstack"; + OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"}; + OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm"; + OIDC_ISSUER_DISCOVER = true; + } + ''; + description = '' + BookStack configuration options to set in the + <filename>.env</filename> file. + + Refer to <link xlink:href="https://www.bookstackapp.com/docs/"/> + for details on supported values. + + Settings containing secret data should be set to an attribute + set containing the attribute <literal>_secret</literal> - a + string pointing to a file containing the value the option + should be set to. See the example to get a better picture of + this: in the resulting <filename>.env</filename> file, the + <literal>OIDC_CLIENT_SECRET</literal> key will be set to the + contents of the <filename>/run/keys/oidc_secret</filename> + file. + ''; + }; + + }; + + config = mkIf cfg.enable { + + assertions = [ + { assertion = db.createLocally -> db.user == user; + message = "services.bookstack.database.user must be set to ${user} if services.bookstack.database.createLocally is set true."; + } + { assertion = db.createLocally -> db.passwordFile == null; + message = "services.bookstack.database.passwordFile cannot be specified if services.bookstack.database.createLocally is set to true."; + } + ]; + + services.bookstack.config = { + APP_KEY._secret = cfg.appKeyFile; + APP_URL = cfg.appURL; + DB_HOST = db.host; + DB_PORT = db.port; + DB_DATABASE = db.name; + DB_USERNAME = db.user; + MAIL_DRIVER = mail.driver; + MAIL_FROM_NAME = mail.fromName; + MAIL_FROM = mail.from; + MAIL_HOST = mail.host; + MAIL_PORT = mail.port; + MAIL_USERNAME = mail.user; + MAIL_ENCRYPTION = mail.encryption; + DB_PASSWORD._secret = db.passwordFile; + MAIL_PASSWORD._secret = mail.passwordFile; + APP_SERVICES_CACHE = "/run/bookstack/cache/services.php"; + APP_PACKAGES_CACHE = "/run/bookstack/cache/packages.php"; + APP_CONFIG_CACHE = "/run/bookstack/cache/config.php"; + APP_ROUTES_CACHE = "/run/bookstack/cache/routes-v7.php"; + APP_EVENTS_CACHE = "/run/bookstack/cache/events.php"; + SESSION_SECURE_COOKIE = tlsEnabled; + }; + + environment.systemPackages = [ artisan ]; + + services.mysql = mkIf db.createLocally { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ db.name ]; + ensureUsers = [ + { name = db.user; + ensurePermissions = { "${db.name}.*" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.phpfpm.pools.bookstack = { + inherit user; + inherit group; + phpOptions = '' + log_errors = on + post_max_size = ${cfg.maxUploadSize} + upload_max_filesize = ${cfg.maxUploadSize} + ''; + settings = { + "listen.mode" = "0660"; + "listen.owner" = user; + "listen.group" = group; + } // cfg.poolConfig; + }; + + services.nginx = { + enable = mkDefault true; + recommendedTlsSettings = true; + recommendedOptimisation = true; + recommendedGzipSettings = true; + virtualHosts.${cfg.hostname} = mkMerge [ cfg.nginx { + root = mkForce "${bookstack}/public"; + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + "~ \.php$".extraConfig = '' + fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket}; + ''; + "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + }; + }]; + }; + + systemd.services.bookstack-setup = { + description = "Preperation tasks for BookStack"; + before = [ "phpfpm-bookstack.service" ]; + after = optional db.createLocally "mysql.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = user; + WorkingDirectory = "${bookstack}"; + RuntimeDirectory = "bookstack/cache"; + RuntimeDirectoryMode = 0700; + }; + path = [ pkgs.replace-secret ]; + script = + let + isSecret = v: isAttrs v && v ? _secret && isString v._secret; + bookstackEnvVars = lib.generators.toKeyValue { + mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { + mkValueString = v: with builtins; + if isInt v then toString v + else if isString v then v + else if true == v then "true" + else if false == v then "false" + else if isSecret v then hashString "sha256" v._secret + else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; + }; + }; + secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); + mkSecretReplacement = file: '' + replace-secret ${escapeShellArgs [ (builtins.hashString "sha256" file) file "${cfg.dataDir}/.env" ]} + ''; + secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; + filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ {} null ])) cfg.config; + bookstackEnv = pkgs.writeText "bookstack.env" (bookstackEnvVars filteredConfig); + in '' + # error handling + set -euo pipefail + + # set permissions + umask 077 + + # create .env file + install -T -m 0600 -o ${user} ${bookstackEnv} "${cfg.dataDir}/.env" + ${secretReplacements} + if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then + sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" + fi + + # migrate db + ${pkgs.php}/bin/php artisan migrate --force + ''; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0710 ${user} ${group} - -" + "d ${cfg.dataDir}/public 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -" + "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -" + ]; + + users = { + users = mkIf (user == "bookstack") { + bookstack = { + inherit group; + isSystemUser = true; + }; + "${config.services.nginx.user}".extraGroups = [ group ]; + }; + groups = mkIf (group == "bookstack") { + bookstack = {}; + }; + }; + + }; + + meta.maintainers = with maintainers; [ ymarkus ]; +} diff --git a/nixos/modules/services/web-apps/calibre-web.nix b/nixos/modules/services/web-apps/calibre-web.nix new file mode 100644 index 00000000000..704cd2cfa8a --- /dev/null +++ b/nixos/modules/services/web-apps/calibre-web.nix @@ -0,0 +1,165 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.calibre-web; + + inherit (lib) concatStringsSep mkEnableOption mkIf mkOption optional optionalString types; +in +{ + options = { + services.calibre-web = { + enable = mkEnableOption "Calibre-Web"; + + listen = { + ip = mkOption { + type = types.str; + default = "::1"; + description = '' + IP address that Calibre-Web should listen on. + ''; + }; + + port = mkOption { + type = types.port; + default = 8083; + description = '' + Listen port for Calibre-Web. + ''; + }; + }; + + dataDir = mkOption { + type = types.str; + default = "calibre-web"; + description = '' + The directory below <filename>/var/lib</filename> where Calibre-Web stores its data. + ''; + }; + + user = mkOption { + type = types.str; + default = "calibre-web"; + description = "User account under which Calibre-Web runs."; + }; + + group = mkOption { + type = types.str; + default = "calibre-web"; + description = "Group account under which Calibre-Web runs."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for the server. + ''; + }; + + options = { + calibreLibrary = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to Calibre library. + ''; + }; + + enableBookConversion = mkOption { + type = types.bool; + default = false; + description = '' + Configure path to the Calibre's ebook-convert in the DB. + ''; + }; + + enableBookUploading = mkOption { + type = types.bool; + default = false; + description = '' + Allow books to be uploaded via Calibre-Web UI. + ''; + }; + + reverseProxyAuth = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable authorization using auth proxy. + ''; + }; + + header = mkOption { + type = types.str; + default = ""; + description = '' + Auth proxy header name. + ''; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.calibre-web = let + appDb = "/var/lib/${cfg.dataDir}/app.db"; + gdriveDb = "/var/lib/${cfg.dataDir}/gdrive.db"; + calibreWebCmd = "${pkgs.calibre-web}/bin/calibre-web -p ${appDb} -g ${gdriveDb}"; + + settings = concatStringsSep ", " ( + [ + "config_port = ${toString cfg.listen.port}" + "config_uploading = ${if cfg.options.enableBookUploading then "1" else "0"}" + "config_allow_reverse_proxy_header_login = ${if cfg.options.reverseProxyAuth.enable then "1" else "0"}" + "config_reverse_proxy_login_header_name = '${cfg.options.reverseProxyAuth.header}'" + ] + ++ optional (cfg.options.calibreLibrary != null) "config_calibre_dir = '${cfg.options.calibreLibrary}'" + ++ optional cfg.options.enableBookConversion "config_converterpath = '${pkgs.calibre}/bin/ebook-convert'" + ); + in + { + description = "Web app for browsing, reading and downloading eBooks stored in a Calibre database"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + StateDirectory = cfg.dataDir; + ExecStartPre = pkgs.writeShellScript "calibre-web-pre-start" ( + '' + __RUN_MIGRATIONS_AND_EXIT=1 ${calibreWebCmd} + + ${pkgs.sqlite}/bin/sqlite3 ${appDb} "update settings set ${settings}" + '' + optionalString (cfg.options.calibreLibrary != null) '' + test -f ${cfg.options.calibreLibrary}/metadata.db || { echo "Invalid Calibre library"; exit 1; } + '' + ); + + ExecStart = "${calibreWebCmd} -i ${cfg.listen.ip}"; + Restart = "on-failure"; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.listen.port ]; + }; + + users.users = mkIf (cfg.user == "calibre-web") { + calibre-web = { + isSystemUser = true; + group = cfg.group; + }; + }; + + users.groups = mkIf (cfg.group == "calibre-web") { + calibre-web = {}; + }; + }; + + meta.maintainers = with lib.maintainers; [ pborzenkov ]; +} diff --git a/nixos/modules/services/web-apps/code-server.nix b/nixos/modules/services/web-apps/code-server.nix new file mode 100644 index 00000000000..474e9140ae8 --- /dev/null +++ b/nixos/modules/services/web-apps/code-server.nix @@ -0,0 +1,139 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + + cfg = config.services.code-server; + defaultUser = "code-server"; + defaultGroup = defaultUser; + +in { + ###### interface + options = { + services.code-server = { + enable = mkEnableOption "code-server"; + + package = mkOption { + default = pkgs.code-server; + defaultText = "pkgs.code-server"; + description = "Which code-server derivation to use."; + type = types.package; + }; + + extraPackages = mkOption { + default = [ ]; + description = "Packages that are available in the PATH of code-server."; + example = "[ pkgs.go ]"; + type = types.listOf types.package; + }; + + extraEnvironment = mkOption { + type = types.attrsOf types.str; + description = + "Additional environment variables to passed to code-server."; + default = { }; + example = { PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig"; }; + }; + + extraArguments = mkOption { + default = [ "--disable-telemetry" ]; + description = "Additional arguments that passed to code-server"; + example = ''[ "--verbose" ]''; + type = types.listOf types.str; + }; + + host = mkOption { + default = "127.0.0.1"; + description = "The host-ip to bind to."; + type = types.str; + }; + + port = mkOption { + default = 4444; + description = "The port where code-server runs."; + type = types.port; + }; + + auth = mkOption { + default = "password"; + description = "The type of authentication to use."; + type = types.enum [ "none" "password" ]; + }; + + hashedPassword = mkOption { + default = ""; + description = + "Create the password with: 'echo -n 'thisismypassword' | npx argon2-cli -e'."; + type = types.str; + }; + + user = mkOption { + default = defaultUser; + example = "yourUser"; + description = '' + The user to run code-server as. + By default, a user named <literal>${defaultUser}</literal> will be created. + ''; + type = types.str; + }; + + group = mkOption { + default = defaultGroup; + example = "yourGroup"; + description = '' + The group to run code-server under. + By default, a group named <literal>${defaultGroup}</literal> will be created. + ''; + type = types.str; + }; + + extraGroups = mkOption { + default = [ ]; + description = + "An array of additional groups for the <literal>${defaultUser}</literal> user."; + example = [ "docker" ]; + type = types.listOf types.str; + }; + + }; + }; + + ###### implementation + config = mkIf cfg.enable { + systemd.services.code-server = { + description = "VSCode server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + path = cfg.extraPackages; + environment = { + HASHED_PASSWORD = cfg.hashedPassword; + } // cfg.extraEnvironment; + serviceConfig = { + ExecStart = "${cfg.package}/bin/code-server --bind-addr ${cfg.host}:${toString cfg.port} --auth ${cfg.auth} " + builtins.concatStringsSep " " cfg.extraArguments; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + RuntimeDirectory = cfg.user; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + }; + + }; + + users.users."${cfg.user}" = mkMerge [ + (mkIf (cfg.user == defaultUser) { + isNormalUser = true; + description = "code-server user"; + inherit (cfg) group; + }) + { + packages = cfg.extraPackages; + inherit (cfg) extraGroups; + } + ]; + + users.groups."${defaultGroup}" = mkIf (cfg.group == defaultGroup) { }; + + }; + + meta.maintainers = with maintainers; [ stackshadow ]; +} diff --git a/nixos/modules/services/web-apps/convos.nix b/nixos/modules/services/web-apps/convos.nix new file mode 100644 index 00000000000..8be11eec9f3 --- /dev/null +++ b/nixos/modules/services/web-apps/convos.nix @@ -0,0 +1,72 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.convos; +in +{ + options.services.convos = { + enable = mkEnableOption "Convos"; + listenPort = mkOption { + type = types.port; + default = 3000; + example = 8080; + description = "Port the web interface should listen on"; + }; + listenAddress = mkOption { + type = types.str; + default = "*"; + example = "127.0.0.1"; + description = "Address or host the web interface should listen on"; + }; + reverseProxy = mkOption { + type = types.bool; + default = false; + description = '' + Enables reverse proxy support. This will allow Convos to automatically + pick up the <literal>X-Forwarded-For</literal> and + <literal>X-Request-Base</literal> HTTP headers set in your reverse proxy + web server. Note that enabling this option without a reverse proxy in + front will be a security issue. + ''; + }; + }; + config = mkIf cfg.enable { + systemd.services.convos = { + description = "Convos Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + environment = { + CONVOS_HOME = "%S/convos"; + CONVOS_REVERSE_PROXY = if cfg.reverseProxy then "1" else "0"; + MOJO_LISTEN = "http://${toString cfg.listenAddress}:${toString cfg.listenPort}"; + }; + serviceConfig = { + ExecStart = "${pkgs.convos}/bin/convos daemon"; + Restart = "on-failure"; + StateDirectory = "convos"; + WorkingDirectory = "%S/convos"; + DynamicUser = true; + MemoryDenyWriteExecute = true; + ProtectHome = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateUsers = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictNamespaces = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6"]; + SystemCallFilter = "@system-service"; + SystemCallArchitectures = "native"; + CapabilityBoundingSet = ""; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/cryptpad.nix b/nixos/modules/services/web-apps/cryptpad.nix new file mode 100644 index 00000000000..e6772de768e --- /dev/null +++ b/nixos/modules/services/web-apps/cryptpad.nix @@ -0,0 +1,54 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.cryptpad; +in +{ + options.services.cryptpad = { + enable = mkEnableOption "the Cryptpad service"; + + package = mkOption { + default = pkgs.cryptpad; + defaultText = literalExpression "pkgs.cryptpad"; + type = types.package; + description = " + Cryptpad package to use. + "; + }; + + configFile = mkOption { + type = types.path; + default = "${cfg.package}/lib/node_modules/cryptpad/config/config.example.js"; + defaultText = literalExpression ''"''${package}/lib/node_modules/cryptpad/config/config.example.js"''; + description = '' + Path to the JavaScript configuration file. + + See <link + xlink:href="https://github.com/xwiki-labs/cryptpad/blob/master/config/config.example.js"/> + for a configuration example. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.cryptpad = { + description = "Cryptpad Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + serviceConfig = { + DynamicUser = true; + Environment = [ + "CRYPTPAD_CONFIG=${cfg.configFile}" + "HOME=%S/cryptpad" + ]; + ExecStart = "${cfg.package}/bin/cryptpad"; + PrivateTmp = true; + Restart = "always"; + StateDirectory = "cryptpad"; + WorkingDirectory = "%S/cryptpad"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/dex.nix b/nixos/modules/services/web-apps/dex.nix new file mode 100644 index 00000000000..4d4689a4cf2 --- /dev/null +++ b/nixos/modules/services/web-apps/dex.nix @@ -0,0 +1,118 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.dex; + fixClient = client: if client ? secretFile then ((builtins.removeAttrs client [ "secretFile" ]) // { secret = client.secretFile; }) else client; + filteredSettings = mapAttrs (n: v: if n == "staticClients" then (builtins.map fixClient v) else v) cfg.settings; + secretFiles = flatten (builtins.map (c: if c ? secretFile then [ c.secretFile ] else []) (cfg.settings.staticClients or [])); + + settingsFormat = pkgs.formats.yaml {}; + configFile = settingsFormat.generate "config.yaml" filteredSettings; + + startPreScript = pkgs.writeShellScript "dex-start-pre" ('' + '' + (concatStringsSep "\n" (builtins.map (file: '' + ${pkgs.replace-secret}/bin/replace-secret '${file}' '${file}' /run/dex/config.yaml + '') secretFiles))); +in +{ + options.services.dex = { + enable = mkEnableOption "the OpenID Connect and OAuth2 identity provider"; + + settings = mkOption { + type = settingsFormat.type; + default = {}; + example = literalExpression '' + { + # External url + issuer = "http://127.0.0.1:5556/dex"; + storage = { + type = "postgres"; + config.host = "/var/run/postgres"; + }; + web = { + http = "127.0.0.1:5556"; + }; + enablePasswordDB = true; + staticClients = [ + { + id = "oidcclient"; + name = "Client"; + redirectURIs = [ "https://example.com/callback" ]; + secretFile = "/etc/dex/oidcclient"; # The content of `secretFile` will be written into to the config as `secret`. + } + ]; + } + ''; + description = '' + The available options can be found in + <link xlink:href="https://github.com/dexidp/dex/blob/v${pkgs.dex.version}/config.yaml.dist">the example configuration</link>. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.dex = { + description = "dex identity provider"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ] ++ (optional (cfg.settings.storage.type == "postgres") "postgresql.service"); + + serviceConfig = { + ExecStart = "${pkgs.dex-oidc}/bin/dex serve /run/dex/config.yaml"; + ExecStartPre = [ + "${pkgs.coreutils}/bin/install -m 600 ${configFile} /run/dex/config.yaml" + "+${startPreScript}" + ]; + RuntimeDirectory = "dex"; + + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + "-/etc/dex" + ]; + BindPaths = optional (cfg.settings.storage.type == "postgres") "/var/run/postgresql"; + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + # Port needs to be exposed to the host network + #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 = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ]; + TemporaryFileSystem = "/:ro"; + # Does not work well with the temporary root + #UMask = "0066"; + }; + }; + }; + + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixos/modules/services/web-apps/discourse.nix b/nixos/modules/services/web-apps/discourse.nix new file mode 100644 index 00000000000..2c2911aada3 --- /dev/null +++ b/nixos/modules/services/web-apps/discourse.nix @@ -0,0 +1,1087 @@ +{ config, options, lib, pkgs, utils, ... }: + +let + json = pkgs.formats.json {}; + + cfg = config.services.discourse; + opt = options.services.discourse; + + # Keep in sync with https://github.com/discourse/discourse_docker/blob/master/image/base/Dockerfile#L5 + upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13; + + postgresqlPackage = if config.services.postgresql.enable then + config.services.postgresql.package + else + pkgs.postgresql; + + postgresqlVersion = lib.getVersion postgresqlPackage; + + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null; + + tlsEnabled = (cfg.enableACME + || cfg.sslCertificate != null + || cfg.sslCertificateKey != null); +in +{ + options = { + services.discourse = { + enable = lib.mkEnableOption "Discourse, an open source discussion platform"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.discourse; + apply = p: p.override { + plugins = lib.unique (p.enabledPlugins ++ cfg.plugins); + }; + defaultText = lib.literalExpression "pkgs.discourse"; + description = '' + The discourse package to use. + ''; + }; + + hostname = lib.mkOption { + type = lib.types.str; + default = if config.networking.domain != null then + config.networking.fqdn + else + config.networking.hostName; + defaultText = lib.literalExpression "config.networking.fqdn"; + example = "discourse.example.com"; + description = '' + The hostname to serve Discourse on. + ''; + }; + + secretKeyBaseFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/secret_key_base"; + description = '' + The path to a file containing the + <literal>secret_key_base</literal> secret. + + Discourse uses <literal>secret_key_base</literal> to encrypt + the cookie store, which contains session data, and to digest + user auth tokens. + + Needs to be a 64 byte long string of hexadecimal + characters. You can generate one by running + + <screen> + <prompt>$ </prompt>openssl rand -hex 64 >/path/to/secret_key_base_file + </screen> + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + sslCertificate = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/ssl.cert"; + description = '' + The path to the server SSL certificate. Set this to enable + SSL. + ''; + }; + + sslCertificateKey = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/ssl.key"; + description = '' + The path to the server SSL certificate key. Set this to + enable SSL. + ''; + }; + + enableACME = lib.mkOption { + type = lib.types.bool; + default = cfg.sslCertificate == null && cfg.sslCertificateKey == null; + defaultText = lib.literalDocBook '' + <literal>true</literal>, unless <option>services.discourse.sslCertificate</option> + and <option>services.discourse.sslCertificateKey</option> are set. + ''; + description = '' + Whether an ACME certificate should be used to secure + connections to the server. + ''; + }; + + backendSettings = lib.mkOption { + type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ])); + default = {}; + example = lib.literalExpression '' + { + max_reqs_per_ip_per_minute = 300; + max_reqs_per_ip_per_10_seconds = 60; + max_asset_reqs_per_ip_per_10_seconds = 250; + max_reqs_per_ip_mode = "warn+block"; + }; + ''; + description = '' + Additional settings to put in the + <filename>discourse.conf</filename> file. + + Look in the + <link xlink:href="https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf">discourse_defaults.conf</link> + file in the upstream distribution to find available options. + + Setting an option to <literal>null</literal> means + <quote>define variable, but leave right-hand side + empty</quote>. + ''; + }; + + siteSettings = lib.mkOption { + type = json.type; + default = {}; + example = lib.literalExpression '' + { + required = { + title = "My Cats"; + site_description = "Discuss My Cats (and be nice plz)"; + }; + login = { + enable_github_logins = true; + github_client_id = "a2f6dfe838cb3206ce20"; + github_client_secret._secret = /run/keys/discourse_github_client_secret; + }; + }; + ''; + description = '' + Discourse site settings. These are the settings that can be + changed from the UI. This only defines their default values: + they can still be overridden from the UI. + + Available settings can be found by looking in the + <link xlink:href="https://github.com/discourse/discourse/blob/master/config/site_settings.yml">site_settings.yml</link> + file of the upstream distribution. To find a setting's path, + you only need to care about the first two levels; i.e. its + category and name. See the example. + + Settings containing secret data should be set to an + attribute set containing the attribute + <literal>_secret</literal> - a string pointing to a file + containing the value the option should be set to. See the + example to get a better picture of this: in the resulting + <filename>config/nixos_site_settings.json</filename> file, + the <literal>login.github_client_secret</literal> key will + be set to the contents of the + <filename>/run/keys/discourse_github_client_secret</filename> + file. + ''; + }; + + admin = { + skipCreate = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Do not create the admin account, instead rely on other + existing admin accounts. + ''; + }; + + email = lib.mkOption { + type = lib.types.str; + example = "admin@example.com"; + description = '' + The admin user email address. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + example = "admin"; + description = '' + The admin user username. + ''; + }; + + fullName = lib.mkOption { + type = lib.types.str; + description = '' + The admin user's full name. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + description = '' + A path to a file containing the admin user's password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + }; + + nginx.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether an <literal>nginx</literal> virtual host should be + set up to serve Discourse. Only disable if you're planning + to use a different web server, which is not recommended. + ''; + }; + + database = { + pool = lib.mkOption { + type = lib.types.int; + default = 8; + description = '' + Database connection pool size. + ''; + }; + + host = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + Discourse database hostname. <literal>null</literal> means <quote>prefer + local unix socket connection</quote>. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + File containing the Discourse database user password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether a database should be automatically created on the + local host. Set this to <literal>false</literal> if you plan + on provisioning a local database yourself. This has no effect + if <option>services.discourse.database.host</option> is customized. + ''; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = '' + Discourse database name. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + default = "discourse"; + description = '' + Discourse database user. + ''; + }; + + ignorePostgresqlVersion = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to allow other versions of PostgreSQL than the + recommended one. Only effective when + <option>services.discourse.database.createLocally</option> + is enabled. + ''; + }; + }; + + redis = { + host = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = '' + Redis server hostname. + ''; + }; + + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + File containing the Redis password. + + This should be a string, not a nix path, since nix paths are + copied into the world-readable nix store. + ''; + }; + + dbNumber = lib.mkOption { + type = lib.types.int; + default = 0; + description = '' + Redis database number. + ''; + }; + + useSSL = lib.mkOption { + type = lib.types.bool; + default = cfg.redis.host != "localhost"; + defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"''; + description = '' + Connect to Redis with SSL. + ''; + }; + }; + + mail = { + notificationEmailAddress = lib.mkOption { + type = lib.types.str; + default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}"; + defaultText = lib.literalExpression '' + "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}" + ''; + description = '' + The <literal>from:</literal> email address used when + sending all essential system emails. The domain specified + here must have SPF, DKIM and reverse PTR records set + correctly for email to arrive. + ''; + }; + + contactEmailAddress = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Email address of key contact responsible for this + site. Used for critical notifications, as well as on the + <literal>/about</literal> contact form for urgent matters. + ''; + }; + + outgoing = { + serverAddress = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = '' + The address of the SMTP server Discourse should use to + send email. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 25; + description = '' + The port of the SMTP server Discourse should use to + send email. + ''; + }; + + username = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + The username of the SMTP server. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + A file containing the password of the SMTP server account. + + This should be a string, not a nix path, since nix paths + are copied into the world-readable nix store. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = cfg.hostname; + defaultText = lib.literalExpression "config.${opt.hostname}"; + description = '' + HELO domain to use for outgoing mail. + ''; + }; + + authentication = lib.mkOption { + type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]); + default = null; + description = '' + Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + + enableStartTLSAuto = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to try to use StartTLS. + ''; + }; + + opensslVerifyMode = lib.mkOption { + type = lib.types.str; + default = "peer"; + description = '' + How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html + ''; + }; + + forceTLS = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Force implicit TLS as per RFC 8314 3.3. + ''; + }; + }; + + incoming = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to set up Postfix to receive incoming mail. + ''; + }; + + replyEmailAddress = lib.mkOption { + type = lib.types.str; + default = "%{reply_key}@${cfg.hostname}"; + defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"''; + description = '' + Template for reply by email incoming email address, for + example: %{reply_key}@reply.example.com or + replies+%{reply_key}@example.com + ''; + }; + + mailReceiverPackage = lib.mkOption { + type = lib.types.package; + default = pkgs.discourse-mail-receiver; + defaultText = lib.literalExpression "pkgs.discourse-mail-receiver"; + description = '' + The discourse-mail-receiver package to use. + ''; + }; + + apiKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + A file containing the Discourse API key used to add + posts and messages from mail. If left at its default + value <literal>null</literal>, one will be automatically + generated. + + This should be a string, not a nix path, since nix paths + are copied into the world-readable nix store. + ''; + }; + }; + }; + + plugins = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = []; + example = lib.literalExpression '' + with config.services.discourse.package.plugins; [ + discourse-canned-replies + discourse-github + ]; + ''; + description = '' + Plugins to install as part of + <productname>Discourse</productname>, expressed as a list of + derivations. + ''; + }; + + sidekiqProcesses = lib.mkOption { + type = lib.types.int; + default = 1; + description = '' + How many Sidekiq processes should be spawned. + ''; + }; + + unicornTimeout = lib.mkOption { + type = lib.types.int; + default = 30; + description = '' + Time in seconds before a request to Unicorn times out. + + This can be raised if the system Discourse is running on is + too slow to handle many requests within 30 seconds. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null); + message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!"; + } + { + assertion = cfg.hostname != ""; + message = "Could not automatically determine hostname, set service.discourse.hostname manually."; + } + { + assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion); + message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. " + + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. " + + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL."; + } + ]; + + + # Default config values are from `config/discourse_defaults.conf` + # upstream. + services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) { + db_pool = cfg.database.pool; + db_timeout = 5000; + db_connect_timeout = 5; + db_socket = null; + db_host = cfg.database.host; + db_backup_host = null; + db_port = null; + db_backup_port = 5432; + db_name = cfg.database.name; + db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username; + db_password = cfg.database.passwordFile; + db_prepared_statements = false; + db_replica_host = null; + db_replica_port = null; + db_advisory_locks = true; + + inherit (cfg) hostname; + backup_hostname = null; + + smtp_address = cfg.mail.outgoing.serverAddress; + smtp_port = cfg.mail.outgoing.port; + smtp_domain = cfg.mail.outgoing.domain; + smtp_user_name = cfg.mail.outgoing.username; + smtp_password = cfg.mail.outgoing.passwordFile; + smtp_authentication = cfg.mail.outgoing.authentication; + smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto; + smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode; + smtp_force_tls = cfg.mail.outgoing.forceTLS; + + load_mini_profiler = true; + mini_profiler_snapshots_period = 0; + mini_profiler_snapshots_transport_url = null; + mini_profiler_snapshots_transport_auth_key = null; + + cdn_url = null; + cdn_origin_hostname = null; + developer_emails = null; + + redis_host = cfg.redis.host; + redis_port = 6379; + redis_replica_host = null; + redis_replica_port = 6379; + redis_db = cfg.redis.dbNumber; + redis_password = cfg.redis.passwordFile; + redis_skip_client_commands = false; + redis_use_ssl = cfg.redis.useSSL; + + message_bus_redis_enabled = false; + message_bus_redis_host = "localhost"; + message_bus_redis_port = 6379; + message_bus_redis_replica_host = null; + message_bus_redis_replica_port = 6379; + message_bus_redis_db = 0; + message_bus_redis_password = null; + message_bus_redis_skip_client_commands = false; + + enable_cors = false; + cors_origin = ""; + serve_static_assets = false; + sidekiq_workers = 5; + rtl_css = false; + connection_reaper_age = 30; + connection_reaper_interval = 30; + relative_url_root = null; + message_bus_max_backlog_size = 100; + secret_key_base = cfg.secretKeyBaseFile; + fallback_assets_path = null; + + s3_bucket = null; + s3_region = null; + s3_access_key_id = null; + s3_secret_access_key = null; + s3_use_iam_profile = null; + s3_cdn_url = null; + s3_endpoint = null; + s3_http_continue_timeout = null; + s3_install_cors_rule = null; + + max_user_api_reqs_per_minute = 20; + max_user_api_reqs_per_day = 2880; + max_admin_api_reqs_per_minute = 60; + max_reqs_per_ip_per_minute = 200; + max_reqs_per_ip_per_10_seconds = 50; + max_asset_reqs_per_ip_per_10_seconds = 200; + max_reqs_per_ip_mode = "block"; + max_reqs_rate_limit_on_private = false; + skip_per_ip_rate_limit_trust_level = 1; + force_anonymous_min_queue_seconds = 1; + force_anonymous_min_per_10_seconds = 3; + background_requests_max_queue_length = 0.5; + reject_message_bus_queue_seconds = 0.1; + disable_search_queue_threshold = 1; + max_old_rebakes_per_15_minutes = 300; + max_logster_logs = 1000; + refresh_maxmind_db_during_precompile_days = 2; + maxmind_backup_path = null; + maxmind_license_key = null; + enable_performance_http_headers = false; + enable_js_error_reporting = true; + mini_scheduler_workers = 5; + compress_anon_cache = false; + anon_cache_store_threshold = 2; + allowed_theme_repos = null; + enable_email_sync_demon = false; + max_digests_enqueued_per_30_mins_per_site = 10000; + cluster_name = null; + multisite_config_path = "config/multisite.yml"; + enable_long_polling = null; + long_polling_interval = null; + }; + + services.redis.enable = lib.mkDefault (cfg.redis.host == "localhost"); + + services.postgresql = lib.mkIf databaseActuallyCreateLocally { + enable = true; + ensureUsers = [{ name = "discourse"; }]; + }; + + # The postgresql module doesn't currently support concepts like + # objects owners and extensions; for now we tack on what's needed + # here. + systemd.services.discourse-postgresql = + let + pgsql = config.services.postgresql; + in + lib.mkIf databaseActuallyCreateLocally { + after = [ "postgresql.service" ]; + bindsTo = [ "postgresql.service" ]; + wantedBy = [ "discourse.service" ]; + partOf = [ "discourse.service" ]; + path = [ + pgsql.package + ]; + script = '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"' + psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm" + psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore" + ''; + + serviceConfig = { + User = pgsql.superUser; + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + + systemd.services.discourse = { + wantedBy = [ "multi-user.target" ]; + after = [ + "redis.service" + "postgresql.service" + "discourse-postgresql.service" + ]; + bindsTo = [ + "redis.service" + ] ++ lib.optionals (cfg.database.host == null) [ + "postgresql.service" + "discourse-postgresql.service" + ]; + path = cfg.package.runtimeDeps ++ [ + postgresqlPackage + pkgs.replace-secret + cfg.package.rake + ]; + environment = cfg.package.runtimeEnv // { + UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout; + UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses; + MALLOC_ARENA_MAX = "2"; + }; + + preStart = + let + discourseKeyValue = lib.generators.toKeyValue { + mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " { + mkValueString = v: with builtins; + if isInt v then toString v + else if isString v then ''"${v}"'' + else if true == v then "true" + else if false == v then "false" + else if null == v then "" + else if isFloat v then lib.strings.floatToString v + else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; + }; + }; + + discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings); + + mkSecretReplacement = file: + lib.optionalString (file != null) '' + replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf + ''; + + mkAdmin = '' + export ADMIN_EMAIL="${cfg.admin.email}" + export ADMIN_NAME="${cfg.admin.fullName}" + export ADMIN_USERNAME="${cfg.admin.username}" + ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})" + export ADMIN_PASSWORD + discourse-rake admin:create_noninteractively + ''; + + in '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + umask u=rwx,g=rx,o= + + rm -rf /var/lib/discourse/tmp/* + + cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/ + cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/ + ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads + ln -sf /var/lib/discourse/backups /run/discourse/public/backups + + ( + umask u=rwx,g=,o= + + ${utils.genJqSecretsReplacementSnippet + cfg.siteSettings + "/run/discourse/config/nixos_site_settings.json" + } + install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf + ${mkSecretReplacement cfg.database.passwordFile} + ${mkSecretReplacement cfg.mail.outgoing.passwordFile} + ${mkSecretReplacement cfg.redis.passwordFile} + ${mkSecretReplacement cfg.secretKeyBaseFile} + chmod 0400 /run/discourse/config/discourse.conf + ) + + discourse-rake db:migrate >>/var/log/discourse/db_migration.log + chmod -R u+w /var/lib/discourse/tmp/ + + ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin} + + discourse-rake themes:update + discourse-rake uploads:regenerate_missing_optimized + ''; + + serviceConfig = { + Type = "simple"; + User = "discourse"; + Group = "discourse"; + RuntimeDirectory = map (p: "discourse/" + p) [ + "config" + "home" + "assets/javascripts/plugins" + "public" + "sockets" + ]; + RuntimeDirectoryMode = 0750; + StateDirectory = map (p: "discourse/" + p) [ + "uploads" + "backups" + "tmp" + ]; + StateDirectoryMode = 0750; + LogsDirectory = "discourse"; + TimeoutSec = "infinity"; + Restart = "on-failure"; + WorkingDirectory = "${cfg.package}/share/discourse"; + + RemoveIPC = true; + PrivateTmp = true; + NoNewPrivileges = true; + RestrictSUIDSGID = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + + ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb"; + }; + }; + + services.nginx = lib.mkIf cfg.nginx.enable { + enable = true; + additionalModules = [ pkgs.nginxModules.brotli ]; + + recommendedTlsSettings = true; + recommendedOptimisation = true; + recommendedGzipSettings = true; + recommendedProxySettings = true; + + upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {}; + + appendHttpConfig = '' + # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) + # levels means it is a 2 deep hierarchy cause we can have lots of files + # max_size limits the size of the cache + proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m; + + # see: https://meta.discourse.org/t/x/74060 + proxy_buffer_size 8k; + ''; + + virtualHosts.${cfg.hostname} = { + inherit (cfg) sslCertificate sslCertificateKey enableACME; + forceSSL = lib.mkDefault tlsEnabled; + + root = "${cfg.package}/share/discourse/public"; + + locations = + let + proxy = { extraConfig ? "" }: { + proxyPass = "http://discourse"; + extraConfig = extraConfig + '' + proxy_set_header X-Request-Start "t=''${msec}"; + ''; + }; + cache = time: '' + expires ${time}; + add_header Cache-Control public,immutable; + ''; + cache_1y = cache "1y"; + cache_1d = cache "1d"; + in + { + "/".tryFiles = "$uri @discourse"; + "@discourse" = proxy {}; + "^~ /backups/".extraConfig = '' + internal; + ''; + "/favicon.ico" = { + return = "204"; + extraConfig = '' + access_log off; + log_not_found off; + ''; + }; + "~ ^/uploads/short-url/" = proxy {}; + "~ ^/secure-media-uploads/" = proxy {}; + "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + '' + add_header Access-Control-Allow-Origin *; + ''; + "/srv/status" = proxy { + extraConfig = '' + access_log off; + log_not_found off; + ''; + }; + "~ ^/javascripts/".extraConfig = cache_1d; + "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + '' + # asset pipeline enables this + brotli_static on; + gzip_static on; + ''; + "~ ^/plugins/".extraConfig = cache_1y; + "~ /images/emoji/".extraConfig = cache_1y; + "~ ^/uploads/" = proxy { + extraConfig = cache_1y + '' + proxy_set_header X-Sendfile-Type X-Accel-Redirect; + proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; + + # custom CSS + location ~ /stylesheet-cache/ { + try_files $uri =404; + } + # this allows us to bypass rails + location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ { + try_files $uri =404; + } + # SVG needs an extra header attached + location ~* \.(svg)$ { + } + # thumbnails & optimized images + location ~ /_?optimized/ { + try_files $uri =404; + } + ''; + }; + "~ ^/admin/backups/" = proxy { + extraConfig = '' + proxy_set_header X-Sendfile-Type X-Accel-Redirect; + proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/; + ''; + }; + "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy { + extraConfig = '' + # if Set-Cookie is in the response nothing gets cached + # this is double bad cause we are not passing last modified in + proxy_ignore_headers "Set-Cookie"; + proxy_hide_header "Set-Cookie"; + proxy_hide_header "X-Discourse-Username"; + proxy_hide_header "X-Runtime"; + + # note x-accel-redirect can not be used with proxy_cache + proxy_cache discourse; + proxy_cache_key "$scheme,$host,$request_uri"; + proxy_cache_valid 200 301 302 7d; + proxy_cache_valid any 1m; + ''; + }; + "/message-bus/" = proxy { + extraConfig = '' + proxy_http_version 1.1; + proxy_buffering off; + ''; + }; + "/downloads/".extraConfig = '' + internal; + alias ${cfg.package}/share/discourse/public/; + ''; + }; + }; + }; + + systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable ( + let + mail-receiver-environment = { + MAIL_DOMAIN = cfg.hostname; + DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}"; + DISCOURSE_API_KEY = "@api-key@"; + DISCOURSE_API_USERNAME = "system"; + }; + mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment; + in + { + before = [ "postfix.service" ]; + after = [ "discourse.service" ]; + wantedBy = [ "discourse.service" ]; + partOf = [ "discourse.service" ]; + path = [ + cfg.package.rake + pkgs.jq + ]; + preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then + discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key + fi + ''; + script = + let + apiKeyPath = + if cfg.mail.incoming.apiKeyFile == null then + "/var/lib/discourse-mail-receiver/api_key" + else + cfg.mail.incoming.apiKeyFile; + in '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + api_key=$(<'${apiKeyPath}') + export api_key + + jq <${mail-receiver-json} \ + '.DISCOURSE_API_KEY = $ENV.api_key' \ + >'/run/discourse-mail-receiver/mail-receiver-environment.json' + ''; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + RuntimeDirectory = "discourse-mail-receiver"; + RuntimeDirectoryMode = "0700"; + StateDirectory = "discourse-mail-receiver"; + User = "discourse"; + Group = "discourse"; + }; + }); + + services.discourse.siteSettings = { + required = { + notification_email = cfg.mail.notificationEmailAddress; + contact_email = cfg.mail.contactEmailAddress; + }; + email = { + manual_polling_enabled = cfg.mail.incoming.enable; + reply_by_email_enabled = cfg.mail.incoming.enable; + reply_by_email_address = cfg.mail.incoming.replyEmailAddress; + }; + }; + + services.postfix = lib.mkIf cfg.mail.incoming.enable { + enable = true; + sslCert = if cfg.sslCertificate != null then cfg.sslCertificate else ""; + sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else ""; + + origin = cfg.hostname; + relayDomains = [ cfg.hostname ]; + config = { + smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy"; + append_dot_mydomain = lib.mkDefault false; + compatibility_level = "2"; + smtputf8_enable = false; + smtpd_banner = lib.mkDefault "ESMTP server"; + myhostname = lib.mkDefault cfg.hostname; + mydestination = lib.mkDefault "localhost"; + }; + transport = '' + ${cfg.hostname} discourse-mail-receiver: + ''; + masterConfig = { + "discourse-mail-receiver" = { + type = "unix"; + privileged = true; + chroot = false; + command = "pipe"; + args = [ + "user=discourse" + "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail" + "\${recipient}" + ]; + }; + "discourse-policy" = { + type = "unix"; + privileged = true; + chroot = false; + command = "spawn"; + args = [ + "user=discourse" + "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection" + ]; + }; + }; + }; + + users.users = { + discourse = { + group = "discourse"; + isSystemUser = true; + }; + } // (lib.optionalAttrs cfg.nginx.enable { + ${config.services.nginx.user}.extraGroups = [ "discourse" ]; + }); + + users.groups = { + discourse = {}; + }; + + environment.systemPackages = [ + cfg.package.rake + ]; + }; + + meta.doc = ./discourse.xml; + meta.maintainers = [ lib.maintainers.talyz ]; +} diff --git a/nixos/modules/services/web-apps/discourse.xml b/nixos/modules/services/web-apps/discourse.xml new file mode 100644 index 00000000000..ad9b65abf51 --- /dev/null +++ b/nixos/modules/services/web-apps/discourse.xml @@ -0,0 +1,355 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-discourse"> + <title>Discourse</title> + <para> + <link xlink:href="https://www.discourse.org/">Discourse</link> is a + modern and open source discussion platform. + </para> + + <section xml:id="module-services-discourse-basic-usage"> + <title>Basic usage</title> + <para> + A minimal configuration using Let's Encrypt for TLS certificates looks like this: +<programlisting> +services.discourse = { + <link linkend="opt-services.discourse.enable">enable</link> = true; + <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com"; + admin = { + <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com"; + <link linkend="opt-services.discourse.admin.username">username</link> = "admin"; + <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator"; + <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file"; + }; + <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file"; +}; +<link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com"; +<link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true; +</programlisting> + </para> + + <para> + Provided a proper DNS setup, you'll be able to connect to the + instance at <literal>discourse.example.com</literal> and log in + using the credentials provided in + <literal>services.discourse.admin</literal>. + </para> + </section> + + <section xml:id="module-services-discourse-tls"> + <title>Using a regular TLS certificate</title> + <para> + To set up TLS using a regular certificate and key on file, use + the <xref linkend="opt-services.discourse.sslCertificate" /> + and <xref linkend="opt-services.discourse.sslCertificateKey" /> + options: + +<programlisting> +services.discourse = { + <link linkend="opt-services.discourse.enable">enable</link> = true; + <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com"; + <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate"; + <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key"; + admin = { + <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com"; + <link linkend="opt-services.discourse.admin.username">username</link> = "admin"; + <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator"; + <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file"; + }; + <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file"; +}; +</programlisting> + + </para> + </section> + + <section xml:id="module-services-discourse-database"> + <title>Database access</title> + <para> + <productname>Discourse</productname> uses + <productname>PostgreSQL</productname> to store most of its + data. A database will automatically be enabled and a database + and role created unless <xref + linkend="opt-services.discourse.database.host" /> is changed from + its default of <literal>null</literal> or <xref + linkend="opt-services.discourse.database.createLocally" /> is set + to <literal>false</literal>. + </para> + + <para> + External database access can also be configured by setting + <xref linkend="opt-services.discourse.database.host" />, <xref + linkend="opt-services.discourse.database.username" /> and <xref + linkend="opt-services.discourse.database.passwordFile" /> as + appropriate. Note that you need to manually create a database + called <literal>discourse</literal> (or the name you chose in + <xref linkend="opt-services.discourse.database.name" />) and + allow the configured database user full access to it. + </para> + </section> + + <section xml:id="module-services-discourse-mail"> + <title>Email</title> + <para> + In addition to the basic setup, you'll want to configure an SMTP + server <productname>Discourse</productname> can use to send user + registration and password reset emails, among others. You can + also optionally let <productname>Discourse</productname> receive + email, which enables people to reply to threads and conversations + via email. + </para> + + <para> + A basic setup which assumes you want to use your configured <link + linkend="opt-services.discourse.hostname">hostname</link> as + email domain can be done like this: + +<programlisting> +services.discourse = { + <link linkend="opt-services.discourse.enable">enable</link> = true; + <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com"; + <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate"; + <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key"; + admin = { + <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com"; + <link linkend="opt-services.discourse.admin.username">username</link> = "admin"; + <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator"; + <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file"; + }; + mail.outgoing = { + <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587; + <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file"; + }; + <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true; + <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file"; +}; +</programlisting> + + This assumes you have set up an MX record for the address you've + set in <link linkend="opt-services.discourse.hostname">hostname</link> and + requires proper SPF, DKIM and DMARC configuration to be done for + the domain you're sending from, in order for email to be reliably delivered. + </para> + + <para> + If you want to use a different domain for your outgoing email + (for example <literal>example.com</literal> instead of + <literal>discourse.example.com</literal>) you should set + <xref linkend="opt-services.discourse.mail.notificationEmailAddress" /> and + <xref linkend="opt-services.discourse.mail.contactEmailAddress" /> manually. + </para> + + <note> + <para> + Setup of TLS for incoming email is currently only configured + automatically when a regular TLS certificate is used, i.e. when + <xref linkend="opt-services.discourse.sslCertificate" /> and + <xref linkend="opt-services.discourse.sslCertificateKey" /> are + set. + </para> + </note> + + </section> + + <section xml:id="module-services-discourse-settings"> + <title>Additional settings</title> + <para> + Additional site settings and backend settings, for which no + explicit <productname>NixOS</productname> options are provided, + can be set in <xref linkend="opt-services.discourse.siteSettings" /> and + <xref linkend="opt-services.discourse.backendSettings" /> respectively. + </para> + + <section xml:id="module-services-discourse-site-settings"> + <title>Site settings</title> + <para> + <quote>Site settings</quote> are the settings that can be + changed through the <productname>Discourse</productname> + UI. Their <emphasis>default</emphasis> values can be set using + <xref linkend="opt-services.discourse.siteSettings" />. + </para> + + <para> + Settings are expressed as a Nix attribute set which matches the + structure of the configuration in + <link xlink:href="https://github.com/discourse/discourse/blob/master/config/site_settings.yml">config/site_settings.yml</link>. + To find a setting's path, you only need to care about the first + two levels; i.e. its category (e.g. <literal>login</literal>) + and name (e.g. <literal>invite_only</literal>). + </para> + + <para> + Settings containing secret data should be set to an attribute + set containing the attribute <literal>_secret</literal> - a + string pointing to a file containing the value the option + should be set to. See the example. + </para> + </section> + + <section xml:id="module-services-discourse-backend-settings"> + <title>Backend settings</title> + <para> + Settings are expressed as a Nix attribute set which matches the + structure of the configuration in + <link xlink:href="https://github.com/discourse/discourse/blob/stable/config/discourse_defaults.conf">config/discourse.conf</link>. + Empty parameters can be defined by setting them to + <literal>null</literal>. + </para> + </section> + + <section xml:id="module-services-discourse-settings-example"> + <title>Example</title> + <para> + The following example sets the title and description of the + <productname>Discourse</productname> instance and enables + <productname>GitHub</productname> login in the site settings, + and changes a few request limits in the backend settings: +<programlisting> +services.discourse = { + <link linkend="opt-services.discourse.enable">enable</link> = true; + <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com"; + <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate"; + <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key"; + admin = { + <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com"; + <link linkend="opt-services.discourse.admin.username">username</link> = "admin"; + <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator"; + <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file"; + }; + mail.outgoing = { + <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587; + <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file"; + }; + <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true; + <link linkend="opt-services.discourse.siteSettings">siteSettings</link> = { + required = { + title = "My Cats"; + site_description = "Discuss My Cats (and be nice plz)"; + }; + login = { + enable_github_logins = true; + github_client_id = "a2f6dfe838cb3206ce20"; + github_client_secret._secret = /run/keys/discourse_github_client_secret; + }; + }; + <link linkend="opt-services.discourse.backendSettings">backendSettings</link> = { + max_reqs_per_ip_per_minute = 300; + max_reqs_per_ip_per_10_seconds = 60; + max_asset_reqs_per_ip_per_10_seconds = 250; + max_reqs_per_ip_mode = "warn+block"; + }; + <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file"; +}; +</programlisting> + </para> + <para> + In the resulting site settings file, the + <literal>login.github_client_secret</literal> key will be set + to the contents of the + <filename>/run/keys/discourse_github_client_secret</filename> + file. + </para> + </section> + </section> + <section xml:id="module-services-discourse-plugins"> + <title>Plugins</title> + <para> + You can install <productname>Discourse</productname> plugins + using the <xref linkend="opt-services.discourse.plugins" /> + option. Pre-packaged plugins are provided in + <literal><your_discourse_package_here>.plugins</literal>. If + you want the full suite of plugins provided through + <literal>nixpkgs</literal>, you can also set the <xref + linkend="opt-services.discourse.package" /> option to + <literal>pkgs.discourseAllPlugins</literal>. + </para> + + <para> + Plugins can be built with the + <literal><your_discourse_package_here>.mkDiscoursePlugin</literal> + function. Normally, it should suffice to provide a + <literal>name</literal> and <literal>src</literal> attribute. If + the plugin has Ruby dependencies, however, they need to be + packaged in accordance with the <link + xlink:href="https://nixos.org/manual/nixpkgs/stable/#developing-with-ruby">Developing + with Ruby</link> section of the Nixpkgs manual and the + appropriate gem options set in <literal>bundlerEnvArgs</literal> + (normally <literal>gemdir</literal> is sufficient). A plugin's + Ruby dependencies are listed in its + <filename>plugin.rb</filename> file as function calls to + <literal>gem</literal>. To construct the corresponding + <filename>Gemfile</filename> manually, run <command>bundle + init</command>, then add the <literal>gem</literal> lines to it + verbatim. + </para> + + <para> + Much of the packaging can be done automatically by the + <filename>nixpkgs/pkgs/servers/web-apps/discourse/update.py</filename> + script - just add the plugin to the <literal>plugins</literal> + list in the <function>update_plugins</function> function and run + the script: + <programlisting language="bash"> +./update.py update-plugins +</programlisting> + </para> + + <para> + Some plugins provide <link + linkend="module-services-discourse-site-settings">site + settings</link>. Their defaults can be configured using <xref + linkend="opt-services.discourse.siteSettings" />, just like + regular site settings. To find the names of these settings, look + in the <literal>config/settings.yml</literal> file of the plugin + repo. + </para> + + <para> + For example, to add the <link + xlink:href="https://github.com/discourse/discourse-spoiler-alert">discourse-spoiler-alert</link> + and <link + xlink:href="https://github.com/discourse/discourse-solved">discourse-solved</link> + plugins, and disable <literal>discourse-spoiler-alert</literal> + by default: + +<programlisting> +services.discourse = { + <link linkend="opt-services.discourse.enable">enable</link> = true; + <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com"; + <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate"; + <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key"; + admin = { + <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com"; + <link linkend="opt-services.discourse.admin.username">username</link> = "admin"; + <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator"; + <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file"; + }; + mail.outgoing = { + <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587; + <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com"; + <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file"; + }; + <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true; + <link linkend="opt-services.discourse.mail.incoming.enable">plugins</link> = with config.services.discourse.package.plugins; [ + discourse-spoiler-alert + discourse-solved + ]; + <link linkend="opt-services.discourse.siteSettings">siteSettings</link> = { + plugins = { + spoiler_enabled = false; + }; + }; + <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file"; +}; +</programlisting> + + </para> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/documize.nix b/nixos/modules/services/web-apps/documize.nix new file mode 100644 index 00000000000..7f2ed82ee33 --- /dev/null +++ b/nixos/modules/services/web-apps/documize.nix @@ -0,0 +1,150 @@ +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.services.documize; + + mkParams = optional: concatMapStrings (name: let + predicate = optional -> cfg.${name} != null; + template = " -${name} '${toString cfg.${name}}'"; + in optionalString predicate template); + +in { + options.services.documize = { + enable = mkEnableOption "Documize Wiki"; + + stateDirectoryName = mkOption { + type = types.str; + default = "documize"; + description = '' + The name of the directory below <filename>/var/lib/private</filename> + where documize runs in and stores, for example, backups. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.documize-community; + defaultText = literalExpression "pkgs.documize-community"; + description = '' + Which package to use for documize. + ''; + }; + + salt = mkOption { + type = types.nullOr types.str; + default = null; + example = "3edIYV6c8B28b19fh"; + description = '' + The salt string used to encode JWT tokens, if not set a random value will be generated. + ''; + }; + + cert = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The <filename>cert.pem</filename> file used for https. + ''; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The <filename>key.pem</filename> file used for https. + ''; + }; + + port = mkOption { + type = types.port; + default = 5001; + description = '' + The http/https port number. + ''; + }; + + forcesslport = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + Redirect given http port number to TLS. + ''; + }; + + offline = mkOption { + type = types.bool; + default = false; + description = '' + Set <literal>true</literal> for offline mode. + ''; + apply = v: if true == v then 1 else 0; + }; + + dbtype = mkOption { + type = types.enum [ "mysql" "percona" "mariadb" "postgresql" "sqlserver" ]; + default = "postgresql"; + description = '' + Specify the database provider: + <simplelist type='inline'> + <member><literal>mysql</literal></member> + <member><literal>percona</literal></member> + <member><literal>mariadb</literal></member> + <member><literal>postgresql</literal></member> + <member><literal>sqlserver</literal></member> + </simplelist> + ''; + }; + + db = mkOption { + type = types.str; + description = '' + Database specific connection string for example: + <itemizedlist> + <listitem><para>MySQL/Percona/MariaDB: + <literal>user:password@tcp(host:3306)/documize</literal> + </para></listitem> + <listitem><para>MySQLv8+: + <literal>user:password@tcp(host:3306)/documize?allowNativePasswords=true</literal> + </para></listitem> + <listitem><para>PostgreSQL: + <literal>host=localhost port=5432 dbname=documize user=admin password=secret sslmode=disable</literal> + </para></listitem> + <listitem><para>MSSQL: + <literal>sqlserver://username:password@localhost:1433?database=Documize</literal> or + <literal>sqlserver://sa@localhost/SQLExpress?database=Documize</literal> + </para></listitem> + </itemizedlist> + ''; + }; + + location = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + reserved + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.documize-server = { + description = "Documize Wiki"; + documentation = [ "https://documize.com/" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + ExecStart = concatStringsSep " " [ + "${cfg.package}/bin/documize" + (mkParams false [ "db" "dbtype" "port" ]) + (mkParams true [ "offline" "location" "forcesslport" "key" "cert" "salt" ]) + ]; + Restart = "always"; + DynamicUser = "yes"; + StateDirectory = cfg.stateDirectoryName; + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/dokuwiki.nix b/nixos/modules/services/web-apps/dokuwiki.nix new file mode 100644 index 00000000000..1f8ca742db9 --- /dev/null +++ b/nixos/modules/services/web-apps/dokuwiki.nix @@ -0,0 +1,439 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.dokuwiki; + eachSite = cfg.sites; + user = "dokuwiki"; + webserver = config.services.${cfg.webserver}; + stateDir = hostName: "/var/lib/dokuwiki/${hostName}/data"; + + dokuwikiAclAuthConfig = hostName: cfg: pkgs.writeText "acl.auth-${hostName}.php" '' + # acl.auth.php + # <?php exit()?> + # + # Access Control Lists + # + ${toString cfg.acl} + ''; + + dokuwikiLocalConfig = hostName: cfg: pkgs.writeText "local-${hostName}.php" '' + <?php + $conf['savedir'] = '${cfg.stateDir}'; + $conf['superuser'] = '${toString cfg.superUser}'; + $conf['useacl'] = '${toString cfg.aclUse}'; + $conf['disableactions'] = '${cfg.disableActions}'; + ${toString cfg.extraConfig} + ''; + + dokuwikiPluginsLocalConfig = hostName: cfg: pkgs.writeText "plugins.local-${hostName}.php" '' + <?php + ${cfg.pluginsConfig} + ''; + + + pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { + pname = "dokuwiki-${hostName}"; + version = src.version; + src = cfg.package; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + # symlink the dokuwiki config + ln -s ${dokuwikiLocalConfig hostName cfg} $out/share/dokuwiki/local.php + + # symlink plugins config + ln -s ${dokuwikiPluginsLocalConfig hostName cfg} $out/share/dokuwiki/plugins.local.php + + # symlink acl + ln -s ${dokuwikiAclAuthConfig hostName cfg} $out/share/dokuwiki/acl.auth.php + + # symlink additional plugin(s) and templates(s) + ${concatMapStringsSep "\n" (template: "ln -s ${template} $out/share/dokuwiki/lib/tpl/${template.name}") cfg.templates} + ${concatMapStringsSep "\n" (plugin: "ln -s ${plugin} $out/share/dokuwiki/lib/plugins/${plugin.name}") cfg.plugins} + ''; + }; + + siteOpts = { config, lib, name, ... }: + { + options = { + enable = mkEnableOption "DokuWiki web application."; + + package = mkOption { + type = types.package; + default = pkgs.dokuwiki; + defaultText = literalExpression "pkgs.dokuwiki"; + description = "Which DokuWiki package to use."; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/dokuwiki/${name}/data"; + description = "Location of the DokuWiki state directory."; + }; + + acl = mkOption { + type = types.nullOr types.lines; + default = null; + example = "* @ALL 8"; + description = '' + Access Control Lists: see <link xlink:href="https://www.dokuwiki.org/acl"/> + Mutually exclusive with services.dokuwiki.aclFile + Set this to a value other than null to take precedence over aclFile option. + + Warning: Consider using aclFile instead if you do not + want to store the ACL in the world-readable Nix store. + ''; + }; + + aclFile = mkOption { + type = with types; nullOr str; + default = if (config.aclUse && config.acl == null) then "/var/lib/dokuwiki/${name}/acl.auth.php" else null; + description = '' + Location of the dokuwiki acl rules. Mutually exclusive with services.dokuwiki.acl + Mutually exclusive with services.dokuwiki.acl which is preferred. + Consult documentation <link xlink:href="https://www.dokuwiki.org/acl"/> for further instructions. + Example: <link xlink:href="https://github.com/splitbrain/dokuwiki/blob/master/conf/acl.auth.php.dist"/> + ''; + example = "/var/lib/dokuwiki/${name}/acl.auth.php"; + }; + + aclUse = mkOption { + type = types.bool; + default = true; + description = '' + Necessary for users to log in into the system. + Also limits anonymous users. When disabled, + everyone is able to create and edit content. + ''; + }; + + pluginsConfig = mkOption { + type = types.lines; + default = '' + $plugins['authad'] = 0; + $plugins['authldap'] = 0; + $plugins['authmysql'] = 0; + $plugins['authpgsql'] = 0; + ''; + description = '' + List of the dokuwiki (un)loaded plugins. + ''; + }; + + superUser = mkOption { + type = types.nullOr types.str; + default = "@admin"; + description = '' + You can set either a username, a list of usernames (“admin1,admin2”), + or the name of a group by prepending an @ char to the groupname + Consult documentation <link xlink:href="https://www.dokuwiki.org/config:superuser"/> for further instructions. + ''; + }; + + usersFile = mkOption { + type = with types; nullOr str; + default = if config.aclUse then "/var/lib/dokuwiki/${name}/users.auth.php" else null; + description = '' + Location of the dokuwiki users file. List of users. Format: + login:passwordhash:Real Name:email:groups,comma,separated + Create passwordHash easily by using:$ mkpasswd -5 password `pwgen 8 1` + Example: <link xlink:href="https://github.com/splitbrain/dokuwiki/blob/master/conf/users.auth.php.dist"/> + ''; + example = "/var/lib/dokuwiki/${name}/users.auth.php"; + }; + + disableActions = mkOption { + type = types.nullOr types.str; + default = ""; + example = "search,register"; + description = '' + Disable individual action modes. Refer to + <link xlink:href="https://www.dokuwiki.org/config:action_modes"/> + for details on supported values. + ''; + }; + + plugins = mkOption { + type = types.listOf types.path; + default = []; + description = '' + List of path(s) to respective plugin(s) which are copied from the 'plugin' directory. + <note><para>These plugins need to be packaged before use, see example.</para></note> + ''; + example = literalExpression '' + let + # Let's package the icalevents plugin + plugin-icalevents = pkgs.stdenv.mkDerivation { + name = "icalevents"; + # Download the plugin from the dokuwiki site + src = pkgs.fetchurl { + url = "https://github.com/real-or-random/dokuwiki-plugin-icalevents/releases/download/2017-06-16/dokuwiki-plugin-icalevents-2017-06-16.zip"; + sha256 = "e40ed7dd6bbe7fe3363bbbecb4de481d5e42385b5a0f62f6a6ce6bf3a1f9dfa8"; + }; + sourceRoot = "."; + # We need unzip to build this package + buildInputs = [ pkgs.unzip ]; + # Installing simply means copying all files to the output directory + installPhase = "mkdir -p $out; cp -R * $out/"; + }; + # And then pass this theme to the plugin list like this: + in [ plugin-icalevents ] + ''; + }; + + templates = mkOption { + type = types.listOf types.path; + default = []; + description = '' + List of path(s) to respective template(s) which are copied from the 'tpl' directory. + <note><para>These templates need to be packaged before use, see example.</para></note> + ''; + example = literalExpression '' + let + # Let's package the bootstrap3 theme + template-bootstrap3 = pkgs.stdenv.mkDerivation { + name = "bootstrap3"; + # Download the theme from the dokuwiki site + src = pkgs.fetchurl { + url = "https://github.com/giterlizzi/dokuwiki-template-bootstrap3/archive/v2019-05-22.zip"; + sha256 = "4de5ff31d54dd61bbccaf092c9e74c1af3a4c53e07aa59f60457a8f00cfb23a6"; + }; + # We need unzip to build this package + buildInputs = [ pkgs.unzip ]; + # Installing simply means copying all files to the output directory + installPhase = "mkdir -p $out; cp -R * $out/"; + }; + # And then pass this theme to the template list like this: + in [ template-bootstrap3 ] + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the DokuWiki PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.nullOr types.lines; + default = null; + example = '' + $conf['title'] = 'My Wiki'; + $conf['userewrite'] = 1; + ''; + description = '' + DokuWiki configuration. Refer to + <link xlink:href="https://www.dokuwiki.org/config"/> + for details on supported values. + ''; + }; + + }; + + }; +in +{ + # interface + options = { + services.dokuwiki = { + + sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = "Specification of one or more DokuWiki sites to serve"; + }; + + webserver = mkOption { + type = types.enum [ "nginx" "caddy" ]; + default = "nginx"; + description = '' + Whether to use nginx or caddy for virtual host management. + + Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.<name></literal>. + See <xref linkend="opt-services.nginx.virtualHosts"/> for further information. + + Further apache2 configuration can be done by adapting <literal>services.httpd.virtualHosts.<name></literal>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + }; + }; + + # implementation + config = mkIf (eachSite != {}) (mkMerge [{ + + assertions = flatten (mapAttrsToList (hostName: cfg: + [{ + assertion = cfg.aclUse -> (cfg.acl != null || cfg.aclFile != null); + message = "Either services.dokuwiki.sites.${hostName}.acl or services.dokuwiki.sites.${hostName}.aclFile is mandatory if aclUse true"; + } + { + assertion = cfg.usersFile != null -> cfg.aclUse != false; + message = "services.dokuwiki.sites.${hostName}.aclUse must must be true if usersFile is not null"; + } + ]) eachSite); + + services.phpfpm.pools = mapAttrs' (hostName: cfg: ( + nameValuePair "dokuwiki-${hostName}" { + inherit user; + group = webserver.group; + + # Not yet compatible with php 8 https://www.dokuwiki.org/requirements + # https://github.com/splitbrain/dokuwiki/issues/3545 + phpPackage = pkgs.php74; + phpEnv = { + DOKUWIKI_LOCAL_CONFIG = "${dokuwikiLocalConfig hostName cfg}"; + DOKUWIKI_PLUGINS_LOCAL_CONFIG = "${dokuwikiPluginsLocalConfig hostName cfg}"; + } // optionalAttrs (cfg.usersFile != null) { + DOKUWIKI_USERS_AUTH_CONFIG = "${cfg.usersFile}"; + } //optionalAttrs (cfg.aclUse) { + DOKUWIKI_ACL_AUTH_CONFIG = if (cfg.acl != null) then "${dokuwikiAclAuthConfig hostName cfg}" else "${toString cfg.aclFile}"; + }; + + settings = { + "listen.owner" = webserver.user; + "listen.group" = webserver.group; + } // cfg.poolConfig; + } + )) eachSite; + + } + + { + systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [ + "d ${stateDir hostName}/attic 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/cache 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/index 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/locks 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/media 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/media_attic 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/media_meta 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/meta 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/pages 0750 ${user} ${webserver.group} - -" + "d ${stateDir hostName}/tmp 0750 ${user} ${webserver.group} - -" + ] ++ lib.optional (cfg.aclFile != null) "C ${cfg.aclFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/acl.auth.php.dist" + ++ lib.optional (cfg.usersFile != null) "C ${cfg.usersFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/users.auth.php.dist" + ) eachSite); + + users.users.${user} = { + group = webserver.group; + isSystemUser = true; + }; + } + + (mkIf (cfg.webserver == "nginx") { + services.nginx = { + enable = true; + virtualHosts = mapAttrs (hostName: cfg: { + serverName = mkDefault hostName; + root = "${pkg hostName cfg}/share/dokuwiki"; + + locations = { + "~ /(conf/|bin/|inc/|install.php)" = { + extraConfig = "deny all;"; + }; + + "~ ^/data/" = { + root = "${stateDir hostName}"; + extraConfig = "internal;"; + }; + + "~ ^/lib.*\.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + + "/" = { + priority = 1; + index = "doku.php"; + extraConfig = ''try_files $uri $uri/ @dokuwiki;''; + }; + + "@dokuwiki" = { + extraConfig = '' + # rewrites "doku.php/" out of the URLs if you set the userwrite setting to .htaccess in dokuwiki config page + rewrite ^/_media/(.*) /lib/exe/fetch.php?media=$1 last; + rewrite ^/_detail/(.*) /lib/exe/detail.php?media=$1 last; + rewrite ^/_export/([^/]+)/(.*) /doku.php?do=export_$1&id=$2 last; + rewrite ^/(.*) /doku.php?id=$1&$args last; + ''; + }; + + "~ \\.php$" = { + extraConfig = '' + try_files $uri $uri/ /doku.php; + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param REDIRECT_STATUS 200; + fastcgi_pass unix:${config.services.phpfpm.pools."dokuwiki-${hostName}".socket}; + ''; + }; + + }; + }) eachSite; + }; + }) + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' (hostName: cfg: ( + nameValuePair "http://${hostName}" { + extraConfig = '' + root * ${pkg hostName cfg}/share/dokuwiki + file_server + + encode zstd gzip + php_fastcgi unix/${config.services.phpfpm.pools."dokuwiki-${hostName}".socket} + + @restrict_files { + path /data/* /conf/* /bin/* /inc/* /vendor/* /install.php + } + + respond @restrict_files 404 + + @allow_media { + path_regexp path ^/_media/(.*)$ + } + rewrite @allow_media /lib/exe/fetch.php?media=/{http.regexp.path.1} + + @allow_detail { + path /_detail* + } + rewrite @allow_detail /lib/exe/detail.php?media={path} + + @allow_export { + path /_export* + path_regexp export /([^/]+)/(.*) + } + rewrite @allow_export /doku.php?do=export_{http.regexp.export.1}&id={http.regexp.export.2} + + try_files {path} {path}/ /doku.php?id={path}&{query} + ''; + } + )) eachSite; + }; + }) + + ]); + + meta.maintainers = with maintainers; [ + _1000101 + onny + dandellion + ]; +} diff --git a/nixos/modules/services/web-apps/engelsystem.nix b/nixos/modules/services/web-apps/engelsystem.nix new file mode 100644 index 00000000000..06c3c6dfc3d --- /dev/null +++ b/nixos/modules/services/web-apps/engelsystem.nix @@ -0,0 +1,186 @@ +{ config, lib, pkgs, utils, ... }: + +let + inherit (lib) mkDefault mkEnableOption mkIf mkOption types literalExpression; + cfg = config.services.engelsystem; +in { + options = { + services.engelsystem = { + enable = mkOption { + default = false; + example = true; + description = '' + Whether to enable engelsystem, an online tool for coordinating volunteers + and shifts on large events. + ''; + type = lib.types.bool; + }; + + domain = mkOption { + type = types.str; + example = "engelsystem.example.com"; + description = "Domain to serve on."; + }; + + package = mkOption { + type = types.package; + description = "Engelsystem package used for the service."; + default = pkgs.engelsystem; + defaultText = literalExpression "pkgs.engelsystem"; + }; + + createDatabase = mkOption { + type = types.bool; + default = true; + description = '' + Whether to create a local database automatically. + This will override every database setting in <option>services.engelsystem.config</option>. + ''; + }; + }; + + services.engelsystem.config = mkOption { + type = types.attrs; + default = { + database = { + host = "localhost"; + database = "engelsystem"; + username = "engelsystem"; + }; + }; + example = { + maintenance = false; + database = { + host = "database.example.com"; + database = "engelsystem"; + username = "engelsystem"; + password._secret = "/var/keys/engelsystem/database"; + }; + email = { + driver = "smtp"; + host = "smtp.example.com"; + port = 587; + from.address = "engelsystem@example.com"; + from.name = "example engelsystem"; + encryption = "tls"; + username = "engelsystem@example.com"; + password._secret = "/var/keys/engelsystem/mail"; + }; + autoarrive = true; + min_password_length = 6; + default_locale = "de_DE"; + }; + description = '' + Options to be added to config.php, as a nix attribute set. Options containing secret data + should be set to an attribute set containing the attribute _secret - a string pointing to a + file containing the value the option should be set to. See the example to get a better + picture of this: in the resulting config.php file, the email.password key will be set to + the contents of the /var/keys/engelsystem/mail file. + + See https://engelsystem.de/doc/admin/configuration/ for available options. + + Note that the admin user login credentials cannot be set here - they always default to + admin:asdfasdf. Log in and change them immediately. + ''; + }; + }; + + config = mkIf cfg.enable { + # create database + services.mysql = mkIf cfg.createDatabase { + enable = true; + package = mkDefault pkgs.mariadb; + ensureUsers = [{ + name = "engelsystem"; + ensurePermissions = { "engelsystem.*" = "ALL PRIVILEGES"; }; + }]; + ensureDatabases = [ "engelsystem" ]; + }; + + environment.etc."engelsystem/config.php".source = + pkgs.writeText "config.php" '' + <?php + return json_decode(file_get_contents("/var/lib/engelsystem/config.json"), true); + ''; + + services.phpfpm.pools.engelsystem = { + user = "engelsystem"; + settings = { + "listen.owner" = config.services.nginx.user; + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.max_requests" = 500; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 5; + "php_admin_value[error_log]" = "stderr"; + "php_admin_flag[log_errors]" = true; + "catch_workers_output" = true; + }; + }; + + services.nginx = { + enable = true; + virtualHosts."${cfg.domain}".locations = { + "/" = { + root = "${cfg.package}/share/engelsystem/public"; + extraConfig = '' + index index.php; + try_files $uri $uri/ /index.php?$args; + autoindex off; + ''; + }; + "~ \\.php$" = { + root = "${cfg.package}/share/engelsystem/public"; + extraConfig = '' + fastcgi_pass unix:${config.services.phpfpm.pools.engelsystem.socket}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include ${config.services.nginx.package}/conf/fastcgi_params; + include ${config.services.nginx.package}/conf/fastcgi.conf; + ''; + }; + }; + }; + + systemd.services."engelsystem-init" = { + wantedBy = [ "multi-user.target" ]; + serviceConfig = { Type = "oneshot"; }; + script = + let + genConfigScript = pkgs.writeScript "engelsystem-gen-config.sh" + (utils.genJqSecretsReplacementSnippet cfg.config "config.json"); + in '' + umask 077 + mkdir -p /var/lib/engelsystem/storage/app + mkdir -p /var/lib/engelsystem/storage/cache/views + cd /var/lib/engelsystem + ${genConfigScript} + chmod 400 config.json + chown -R engelsystem . + ''; + }; + systemd.services."engelsystem-migrate" = { + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + User = "engelsystem"; + Group = "engelsystem"; + }; + script = '' + ${cfg.package}/bin/migrate + ''; + after = [ "engelsystem-init.service" "mysql.service" ]; + }; + systemd.services."phpfpm-engelsystem".after = + [ "engelsystem-migrate.service" ]; + + users.users.engelsystem = { + isSystemUser = true; + createHome = true; + home = "/var/lib/engelsystem/storage"; + group = "engelsystem"; + }; + users.groups.engelsystem = { }; + }; +} diff --git a/nixos/modules/services/web-apps/ethercalc.nix b/nixos/modules/services/web-apps/ethercalc.nix new file mode 100644 index 00000000000..d74def59c6c --- /dev/null +++ b/nixos/modules/services/web-apps/ethercalc.nix @@ -0,0 +1,62 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.ethercalc; +in { + options = { + services.ethercalc = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + ethercalc, an online collaborative spreadsheet server. + + Persistent state will be maintained under + <filename>/var/lib/ethercalc</filename>. Upstream supports using a + redis server for storage and recommends the redis backend for + intensive use; however, the Nix module doesn't currently support + redis. + + Note that while ethercalc is a good and robust project with an active + issue tracker, there haven't been new commits since the end of 2020. + ''; + }; + + package = mkOption { + default = pkgs.ethercalc; + defaultText = literalExpression "pkgs.ethercalc"; + type = types.package; + description = "Ethercalc package to use."; + }; + + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Address to listen on (use 0.0.0.0 to allow access from any address)."; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = "Port to bind to."; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.ethercalc = { + description = "Ethercalc service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig = { + DynamicUser = true; + ExecStart = "${cfg.package}/bin/ethercalc --host ${cfg.host} --port ${toString cfg.port}"; + Restart = "always"; + StateDirectory = "ethercalc"; + WorkingDirectory = "/var/lib/ethercalc"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/fluidd.nix b/nixos/modules/services/web-apps/fluidd.nix new file mode 100644 index 00000000000..6ac1acc9d03 --- /dev/null +++ b/nixos/modules/services/web-apps/fluidd.nix @@ -0,0 +1,66 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.fluidd; + moonraker = config.services.moonraker; +in +{ + options.services.fluidd = { + enable = mkEnableOption "Fluidd, a Klipper web interface for managing your 3d printer"; + + package = mkOption { + type = types.package; + description = "Fluidd package to be used in the module"; + default = pkgs.fluidd; + defaultText = literalExpression "pkgs.fluidd"; + }; + + hostName = mkOption { + type = types.str; + default = "localhost"; + description = "Hostname to serve fluidd on"; + }; + + nginx = mkOption { + type = types.submodule + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }); + default = { }; + example = literalExpression '' + { + serverAliases = [ "fluidd.''${config.networking.domain}" ]; + } + ''; + description = "Extra configuration for the nginx virtual host of fluidd."; + }; + }; + + config = mkIf cfg.enable { + services.nginx = { + enable = true; + upstreams.fluidd-apiserver.servers."${moonraker.address}:${toString moonraker.port}" = { }; + virtualHosts."${cfg.hostName}" = mkMerge [ + cfg.nginx + { + root = mkForce "${cfg.package}/share/fluidd/htdocs"; + locations = { + "/" = { + index = "index.html"; + tryFiles = "$uri $uri/ /index.html"; + }; + "/index.html".extraConfig = '' + add_header Cache-Control "no-store, no-cache, must-revalidate"; + ''; + "/websocket" = { + proxyWebsockets = true; + proxyPass = "http://fluidd-apiserver/websocket"; + }; + "~ ^/(printer|api|access|machine|server)/" = { + proxyWebsockets = true; + proxyPass = "http://fluidd-apiserver$request_uri"; + }; + }; + } + ]; + }; + }; +} diff --git a/nixos/modules/services/web-apps/galene.nix b/nixos/modules/services/web-apps/galene.nix new file mode 100644 index 00000000000..1d0a620585b --- /dev/null +++ b/nixos/modules/services/web-apps/galene.nix @@ -0,0 +1,185 @@ +{ config, lib, options, pkgs, ... }: + +with lib; +let + cfg = config.services.galene; + opt = options.services.galene; + defaultstateDir = "/var/lib/galene"; + defaultrecordingsDir = "${cfg.stateDir}/recordings"; + defaultgroupsDir = "${cfg.stateDir}/groups"; + defaultdataDir = "${cfg.stateDir}/data"; +in +{ + options = { + services.galene = { + enable = mkEnableOption "Galene Service."; + + stateDir = mkOption { + default = defaultstateDir; + type = types.str; + description = '' + The directory where Galene stores its internal state. If left as the default + value this directory will automatically be created before the Galene server + starts, otherwise the sysadmin is responsible for ensuring the directory + exists with appropriate ownership and permissions. + ''; + }; + + user = mkOption { + type = types.str; + default = "galene"; + description = "User account under which galene runs."; + }; + + group = mkOption { + type = types.str; + default = "galene"; + description = "Group under which galene runs."; + }; + + insecure = mkOption { + type = types.bool; + default = false; + description = '' + Whether Galene should listen in http or in https. If left as the default + value (false), Galene needs to be fed a private key and a certificate. + ''; + }; + + certFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/path/to/your/cert.pem"; + description = '' + Path to the server's certificate. The file is copied at runtime to + Galene's data directory where it needs to reside. + ''; + }; + + keyFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/path/to/your/key.pem"; + description = '' + Path to the server's private key. The file is copied at runtime to + Galene's data directory where it needs to reside. + ''; + }; + + httpAddress = mkOption { + type = types.str; + default = ""; + description = "HTTP listen address for galene."; + }; + + httpPort = mkOption { + type = types.port; + default = 8443; + description = "HTTP listen port."; + }; + + staticDir = mkOption { + type = types.str; + default = "${cfg.package.static}/static"; + defaultText = literalExpression ''"''${package.static}/static"''; + example = "/var/lib/galene/static"; + description = "Web server directory."; + }; + + recordingsDir = mkOption { + type = types.str; + default = defaultrecordingsDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/recordings"''; + example = "/var/lib/galene/recordings"; + description = "Recordings directory."; + }; + + dataDir = mkOption { + type = types.str; + default = defaultdataDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/data"''; + example = "/var/lib/galene/data"; + description = "Data directory."; + }; + + groupsDir = mkOption { + type = types.str; + default = defaultgroupsDir; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/groups"''; + example = "/var/lib/galene/groups"; + description = "Web server directory."; + }; + + package = mkOption { + default = pkgs.galene; + defaultText = literalExpression "pkgs.galene"; + type = types.package; + description = '' + Package for running Galene. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.insecure || (cfg.certFile != null && cfg.keyFile != null); + message = '' + Galene needs both certFile and keyFile defined for encryption, or + the insecure flag. + ''; + } + ]; + + systemd.services.galene = { + description = "galene"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + preStart = '' + ${optionalString (cfg.insecure != true) '' + install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.certFile} ${cfg.dataDir}/cert.pem + install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.keyFile} ${cfg.dataDir}/key.pem + ''} + ''; + + serviceConfig = mkMerge [ + { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + ExecStart = ''${cfg.package}/bin/galene \ + ${optionalString (cfg.insecure) "-insecure"} \ + -data ${cfg.dataDir} \ + -groups ${cfg.groupsDir} \ + -recordings ${cfg.recordingsDir} \ + -static ${cfg.staticDir}''; + Restart = "always"; + # Upstream Requirements + LimitNOFILE = 65536; + StateDirectory = [ ] ++ + optional (cfg.stateDir == defaultstateDir) "galene" ++ + optional (cfg.dataDir == defaultdataDir) "galene/data" ++ + optional (cfg.groupsDir == defaultgroupsDir) "galene/groups" ++ + optional (cfg.recordingsDir == defaultrecordingsDir) "galene/recordings"; + } + ]; + }; + + users.users = mkIf (cfg.user == "galene") + { + galene = { + description = "galene Service"; + group = cfg.group; + isSystemUser = true; + }; + }; + + users.groups = mkIf (cfg.group == "galene") { + galene = { }; + }; + }; + meta.maintainers = with lib.maintainers; [ rgrunbla ]; +} diff --git a/nixos/modules/services/web-apps/gerrit.nix b/nixos/modules/services/web-apps/gerrit.nix new file mode 100644 index 00000000000..6bfc67368dd --- /dev/null +++ b/nixos/modules/services/web-apps/gerrit.nix @@ -0,0 +1,242 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.gerrit; + + # NixOS option type for git-like configs + gitIniType = with types; + let + primitiveType = either str (either bool int); + multipleType = either primitiveType (listOf primitiveType); + sectionType = lazyAttrsOf multipleType; + supersectionType = lazyAttrsOf (either multipleType sectionType); + in lazyAttrsOf supersectionType; + + gerritConfig = pkgs.writeText "gerrit.conf" ( + lib.generators.toGitINI cfg.settings + ); + + replicationConfig = pkgs.writeText "replication.conf" ( + lib.generators.toGitINI cfg.replicationSettings + ); + + # Wrap the gerrit java with all the java options so it can be called + # like a normal CLI app + gerrit-cli = pkgs.writeShellScriptBin "gerrit" '' + set -euo pipefail + jvmOpts=( + ${lib.escapeShellArgs cfg.jvmOpts} + -Xmx${cfg.jvmHeapLimit} + ) + exec ${cfg.jvmPackage}/bin/java \ + "''${jvmOpts[@]}" \ + -jar ${cfg.package}/webapps/${cfg.package.name}.war \ + "$@" + ''; + + gerrit-plugins = pkgs.runCommand + "gerrit-plugins" + { + buildInputs = [ gerrit-cli ]; + } + '' + shopt -s nullglob + mkdir $out + + for name in ${toString cfg.builtinPlugins}; do + echo "Installing builtin plugin $name.jar" + gerrit cat plugins/$name.jar > $out/$name.jar + done + + for file in ${toString cfg.plugins}; do + name=$(echo "$file" | cut -d - -f 2-) + echo "Installing plugin $name" + ln -sf "$file" $out/$name + done + ''; +in +{ + options = { + services.gerrit = { + enable = mkEnableOption "Gerrit service"; + + package = mkOption { + type = types.package; + default = pkgs.gerrit; + defaultText = literalExpression "pkgs.gerrit"; + description = "Gerrit package to use"; + }; + + jvmPackage = mkOption { + type = types.package; + default = pkgs.jre_headless; + defaultText = literalExpression "pkgs.jre_headless"; + description = "Java Runtime Environment package to use"; + }; + + jvmOpts = mkOption { + type = types.listOf types.str; + default = [ + "-Dflogger.backend_factory=com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance" + "-Dflogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance" + ]; + description = "A list of JVM options to start gerrit with."; + }; + + jvmHeapLimit = mkOption { + type = types.str; + default = "1024m"; + description = '' + How much memory to allocate to the JVM heap + ''; + }; + + listenAddress = mkOption { + type = types.str; + default = "[::]:8080"; + description = '' + <literal>hostname:port</literal> to listen for HTTP traffic. + + This is bound using the systemd socket activation. + ''; + }; + + settings = mkOption { + type = gitIniType; + default = {}; + description = '' + Gerrit configuration. This will be generated to the + <literal>etc/gerrit.config</literal> file. + ''; + }; + + replicationSettings = mkOption { + type = gitIniType; + default = {}; + description = '' + Replication configuration. This will be generated to the + <literal>etc/replication.config</literal> file. + ''; + }; + + plugins = mkOption { + type = types.listOf types.package; + default = []; + description = '' + List of plugins to add to Gerrit. Each derivation is a jar file + itself where the name of the derivation is the name of plugin. + ''; + }; + + builtinPlugins = mkOption { + type = types.listOf (types.enum cfg.package.passthru.plugins); + default = []; + description = '' + List of builtins plugins to install. Those are shipped in the + <literal>gerrit.war</literal> file. + ''; + }; + + serverId = mkOption { + type = types.str; + description = '' + Set a UUID that uniquely identifies the server. + + This can be generated with + <literal>nix-shell -p util-linux --run uuidgen</literal>. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = cfg.replicationSettings != {} -> elem "replication" cfg.builtinPlugins; + message = "Gerrit replicationSettings require enabling the replication plugin"; + } + ]; + + services.gerrit.settings = { + cache.directory = "/var/cache/gerrit"; + container.heapLimit = cfg.jvmHeapLimit; + gerrit.basePath = lib.mkDefault "git"; + gerrit.serverId = cfg.serverId; + httpd.inheritChannel = "true"; + httpd.listenUrl = lib.mkDefault "http://${cfg.listenAddress}"; + index.type = lib.mkDefault "lucene"; + }; + + # Add the gerrit CLI to the system to run `gerrit init` and friends. + environment.systemPackages = [ gerrit-cli ]; + + systemd.sockets.gerrit = { + unitConfig.Description = "Gerrit HTTP socket"; + wantedBy = [ "sockets.target" ]; + listenStreams = [ cfg.listenAddress ]; + }; + + systemd.services.gerrit = { + description = "Gerrit"; + + wantedBy = [ "multi-user.target" ]; + requires = [ "gerrit.socket" ]; + after = [ "gerrit.socket" "network.target" ]; + + path = [ + gerrit-cli + pkgs.bash + pkgs.coreutils + pkgs.git + pkgs.openssh + ]; + + environment = { + GERRIT_HOME = "%S/gerrit"; + GERRIT_TMP = "%T"; + HOME = "%S/gerrit"; + XDG_CONFIG_HOME = "%S/gerrit/.config"; + }; + + preStart = '' + set -euo pipefail + + # bootstrap if nothing exists + if [[ ! -d git ]]; then + gerrit init --batch --no-auto-start + fi + + # install gerrit.war for the plugin manager + rm -rf bin + mkdir bin + ln -sfv ${cfg.package}/webapps/${cfg.package.name}.war bin/gerrit.war + + # copy the config, keep it mutable because Gerrit + ln -sfv ${gerritConfig} etc/gerrit.config + ln -sfv ${replicationConfig} etc/replication.config + + # install the plugins + rm -rf plugins + ln -sv ${gerrit-plugins} plugins + '' + ; + + serviceConfig = { + CacheDirectory = "gerrit"; + DynamicUser = true; + ExecStart = "${gerrit-cli}/bin/gerrit daemon --console-log"; + LimitNOFILE = 4096; + StandardInput = "socket"; + StandardOutput = "journal"; + StateDirectory = "gerrit"; + WorkingDirectory = "%S/gerrit"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ edef zimbatm ]; + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixos/modules/services/web-apps/gotify-server.nix b/nixos/modules/services/web-apps/gotify-server.nix new file mode 100644 index 00000000000..03e01f46a94 --- /dev/null +++ b/nixos/modules/services/web-apps/gotify-server.nix @@ -0,0 +1,49 @@ +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.services.gotify; +in { + options = { + services.gotify = { + enable = mkEnableOption "Gotify webserver"; + + port = mkOption { + type = types.port; + description = '' + Port the server listens to. + ''; + }; + + stateDirectoryName = mkOption { + type = types.str; + default = "gotify-server"; + description = '' + The name of the directory below <filename>/var/lib</filename> where + gotify stores its runtime data. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.gotify-server = { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + description = "Simple server for sending and receiving messages"; + + environment = { + GOTIFY_SERVER_PORT = toString cfg.port; + }; + + serviceConfig = { + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + StateDirectory = cfg.stateDirectoryName; + Restart = "always"; + DynamicUser = "yes"; + ExecStart = "${pkgs.gotify-server}/bin/server"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/grocy.nix b/nixos/modules/services/web-apps/grocy.nix new file mode 100644 index 00000000000..be2de638dd9 --- /dev/null +++ b/nixos/modules/services/web-apps/grocy.nix @@ -0,0 +1,172 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.grocy; +in { + options.services.grocy = { + enable = mkEnableOption "grocy"; + + hostName = mkOption { + type = types.str; + description = '' + FQDN for the grocy instance. + ''; + }; + + nginx.enableSSL = mkOption { + type = types.bool; + default = true; + description = '' + Whether or not to enable SSL (with ACME and let's encrypt) + for the grocy vhost. + ''; + }; + + phpfpm.settings = mkOption { + type = with types; attrsOf (oneOf [ int str bool ]); + default = { + "pm" = "dynamic"; + "php_admin_value[error_log]" = "stderr"; + "php_admin_flag[log_errors]" = true; + "listen.owner" = "nginx"; + "catch_workers_output" = true; + "pm.max_children" = "32"; + "pm.start_servers" = "2"; + "pm.min_spare_servers" = "2"; + "pm.max_spare_servers" = "4"; + "pm.max_requests" = "500"; + }; + + description = '' + Options for grocy's PHPFPM pool. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/grocy"; + description = '' + Home directory of the <literal>grocy</literal> user which contains + the application's state. + ''; + }; + + settings = { + currency = mkOption { + type = types.str; + default = "USD"; + example = "EUR"; + description = '' + ISO 4217 code for the currency to display. + ''; + }; + + culture = mkOption { + type = types.enum [ "de" "en" "da" "en_GB" "es" "fr" "hu" "it" "nl" "no" "pl" "pt_BR" "ru" "sk_SK" "sv_SE" "tr" ]; + default = "en"; + description = '' + Display language of the frontend. + ''; + }; + + calendar = { + showWeekNumber = mkOption { + default = true; + type = types.bool; + description = '' + Show the number of the weeks in the calendar views. + ''; + }; + firstDayOfWeek = mkOption { + default = null; + type = types.nullOr (types.enum (range 0 6)); + description = '' + Which day of the week (0=Sunday, 1=Monday etc.) should be the + first day. + ''; + }; + }; + }; + }; + + config = mkIf cfg.enable { + environment.etc."grocy/config.php".text = '' + <?php + Setting('CULTURE', '${cfg.settings.culture}'); + Setting('CURRENCY', '${cfg.settings.currency}'); + Setting('CALENDAR_FIRST_DAY_OF_WEEK', '${toString cfg.settings.calendar.firstDayOfWeek}'); + Setting('CALENDAR_SHOW_WEEK_OF_YEAR', ${boolToString cfg.settings.calendar.showWeekNumber}); + ''; + + users.users.grocy = { + isSystemUser = true; + createHome = true; + home = cfg.dataDir; + group = "nginx"; + }; + + systemd.tmpfiles.rules = map ( + dirName: "d '${cfg.dataDir}/${dirName}' - grocy nginx - -" + ) [ "viewcache" "plugins" "settingoverrides" "storage" ]; + + services.phpfpm.pools.grocy = { + user = "grocy"; + group = "nginx"; + + # PHP 7.4 is the only version which is supported/tested by upstream: + # https://github.com/grocy/grocy/blob/v3.0.0/README.md#how-to-install + phpPackage = pkgs.php74; + + inherit (cfg.phpfpm) settings; + + phpEnv = { + GROCY_CONFIG_FILE = "/etc/grocy/config.php"; + GROCY_DB_FILE = "${cfg.dataDir}/grocy.db"; + GROCY_STORAGE_DIR = "${cfg.dataDir}/storage"; + GROCY_PLUGIN_DIR = "${cfg.dataDir}/plugins"; + GROCY_CACHE_DIR = "${cfg.dataDir}/viewcache"; + }; + }; + + services.nginx = { + enable = true; + virtualHosts."${cfg.hostName}" = mkMerge [ + { root = "${pkgs.grocy}/public"; + locations."/".extraConfig = '' + rewrite ^ /index.php; + ''; + locations."~ \\.php$".extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.grocy.socket}; + include ${config.services.nginx.package}/conf/fastcgi.conf; + include ${config.services.nginx.package}/conf/fastcgi_params; + ''; + locations."~ \\.(js|css|ttf|woff2?|png|jpe?g|svg)$".extraConfig = '' + add_header Cache-Control "public, max-age=15778463"; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Robots-Tag none; + add_header X-Download-Options noopen; + add_header X-Permitted-Cross-Domain-Policies none; + add_header Referrer-Policy no-referrer; + access_log off; + ''; + extraConfig = '' + try_files $uri /index.php; + ''; + } + (mkIf cfg.nginx.enableSSL { + enableACME = true; + forceSSL = true; + }) + ]; + }; + }; + + meta = { + maintainers = with maintainers; [ ma27 ]; + doc = ./grocy.xml; + }; +} diff --git a/nixos/modules/services/web-apps/grocy.xml b/nixos/modules/services/web-apps/grocy.xml new file mode 100644 index 00000000000..fdf6d00f4b1 --- /dev/null +++ b/nixos/modules/services/web-apps/grocy.xml @@ -0,0 +1,77 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-grocy"> + + <title>Grocy</title> + <para> + <link xlink:href="https://grocy.info/">Grocy</link> is a web-based self-hosted groceries + & household management solution for your home. + </para> + + <section xml:id="module-services-grocy-basic-usage"> + <title>Basic usage</title> + <para> + A very basic configuration may look like this: +<programlisting>{ pkgs, ... }: +{ + services.grocy = { + <link linkend="opt-services.grocy.enable">enable</link> = true; + <link linkend="opt-services.grocy.hostName">hostName</link> = "grocy.tld"; + }; +}</programlisting> + This configures a simple vhost using <link linkend="opt-services.nginx.enable">nginx</link> + which listens to <literal>grocy.tld</literal> with fully configured ACME/LE (this can be + disabled by setting <link linkend="opt-services.grocy.nginx.enableSSL">services.grocy.nginx.enableSSL</link> + to <literal>false</literal>). After the initial setup the credentials <literal>admin:admin</literal> + can be used to login. + </para> + <para> + The application's state is persisted at <literal>/var/lib/grocy/grocy.db</literal> in a + <package>sqlite3</package> database. The migration is applied when requesting the <literal>/</literal>-route + of the application. + </para> + </section> + + <section xml:id="module-services-grocy-settings"> + <title>Settings</title> + <para> + The configuration for <literal>grocy</literal> is located at <literal>/etc/grocy/config.php</literal>. + By default, the following settings can be defined in the NixOS-configuration: +<programlisting>{ pkgs, ... }: +{ + services.grocy.settings = { + # The default currency in the system for invoices etc. + # Please note that exchange rates aren't taken into account, this + # is just the setting for what's shown in the frontend. + <link linkend="opt-services.grocy.settings.currency">currency</link> = "EUR"; + + # The display language (and locale configuration) for grocy. + <link linkend="opt-services.grocy.settings.currency">culture</link> = "de"; + + calendar = { + # Whether or not to show the week-numbers + # in the calendar. + <link linkend="opt-services.grocy.settings.calendar.showWeekNumber">showWeekNumber</link> = true; + + # Index of the first day to be shown in the calendar (0=Sunday, 1=Monday, + # 2=Tuesday and so on). + <link linkend="opt-services.grocy.settings.calendar.firstDayOfWeek">firstDayOfWeek</link> = 2; + }; + }; +}</programlisting> + </para> + <para> + If you want to alter the configuration file on your own, you can do this manually with + an expression like this: +<programlisting>{ lib, ... }: +{ + environment.etc."grocy/config.php".text = lib.mkAfter '' + // Arbitrary PHP code in grocy's configuration file + ''; +}</programlisting> + </para> + </section> + +</chapter> diff --git a/nixos/modules/services/web-apps/hedgedoc.nix b/nixos/modules/services/web-apps/hedgedoc.nix new file mode 100644 index 00000000000..9eeabb9d566 --- /dev/null +++ b/nixos/modules/services/web-apps/hedgedoc.nix @@ -0,0 +1,1038 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.hedgedoc; + + # 21.03 will not be an official release - it was instead 21.05. This + # versionAtLeast statement remains set to 21.03 for backwards compatibility. + # See https://github.com/NixOS/nixpkgs/pull/108899 and + # https://github.com/NixOS/rfcs/blob/master/rfcs/0080-nixos-release-schedule.md. + name = if versionAtLeast config.system.stateVersion "21.03" + then "hedgedoc" + else "codimd"; + + prettyJSON = conf: + pkgs.runCommandLocal "hedgedoc-config.json" { + nativeBuildInputs = [ pkgs.jq ]; + } '' + echo '${builtins.toJSON conf}' | jq \ + '{production:del(.[]|nulls)|del(.[][]?|nulls)}' > $out + ''; +in +{ + imports = [ + (mkRenamedOptionModule [ "services" "codimd" ] [ "services" "hedgedoc" ]) + ]; + + options.services.hedgedoc = { + enable = mkEnableOption "the HedgeDoc Markdown Editor"; + + groups = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Groups to which the service user should be added. + ''; + }; + + workDir = mkOption { + type = types.path; + default = "/var/lib/${name}"; + description = '' + Working directory for the HedgeDoc service. + ''; + }; + + configuration = { + debug = mkEnableOption "debug mode"; + domain = mkOption { + type = types.nullOr types.str; + default = null; + example = "hedgedoc.org"; + description = '' + Domain name for the HedgeDoc instance. + ''; + }; + urlPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/url/path/to/hedgedoc"; + description = '' + Path under which HedgeDoc is accessible. + ''; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = '' + Address to listen on. + ''; + }; + port = mkOption { + type = types.int; + default = 3000; + example = 80; + description = '' + Port to listen on. + ''; + }; + path = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/hedgedoc.sock"; + description = '' + Specify where a UNIX domain socket should be placed. + ''; + }; + allowOrigin = mkOption { + type = types.listOf types.str; + default = []; + example = [ "localhost" "hedgedoc.org" ]; + description = '' + List of domains to whitelist. + ''; + }; + useSSL = mkOption { + type = types.bool; + default = false; + description = '' + Enable to use SSL server. This will also enable + <option>protocolUseSSL</option>. + ''; + }; + hsts = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable HSTS if HTTPS is also enabled. + ''; + }; + maxAgeSeconds = mkOption { + type = types.int; + default = 31536000; + description = '' + Max duration for clients to keep the HSTS status. + ''; + }; + includeSubdomains = mkOption { + type = types.bool; + default = true; + description = '' + Whether to include subdomains in HSTS. + ''; + }; + preload = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow preloading of the site's HSTS status. + ''; + }; + }; + csp = mkOption { + type = types.nullOr types.attrs; + default = null; + example = literalExpression '' + { + enable = true; + directives = { + scriptSrc = "trustworthy.scripts.example.com"; + }; + upgradeInsecureRequest = "auto"; + addDefaults = true; + } + ''; + description = '' + Specify the Content Security Policy which is passed to Helmet. + For configuration details see <link xlink:href="https://helmetjs.github.io/docs/csp/" + >https://helmetjs.github.io/docs/csp/</link>. + ''; + }; + protocolUseSSL = mkOption { + type = types.bool; + default = false; + description = '' + Enable to use TLS for resource paths. + This only applies when <option>domain</option> is set. + ''; + }; + urlAddPort = mkOption { + type = types.bool; + default = false; + description = '' + Enable to add the port to callback URLs. + This only applies when <option>domain</option> is set + and only for ports other than 80 and 443. + ''; + }; + useCDN = mkOption { + type = types.bool; + default = false; + description = '' + Whether to use CDN resources or not. + ''; + }; + allowAnonymous = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow anonymous usage. + ''; + }; + allowAnonymousEdits = mkOption { + type = types.bool; + default = false; + description = '' + Whether to allow guests to edit existing notes with the `freely' permission, + when <option>allowAnonymous</option> is enabled. + ''; + }; + allowFreeURL = mkOption { + type = types.bool; + default = false; + description = '' + Whether to allow note creation by accessing a nonexistent note URL. + ''; + }; + defaultPermission = mkOption { + type = types.enum [ "freely" "editable" "limited" "locked" "private" ]; + default = "editable"; + description = '' + Default permissions for notes. + This only applies for signed-in users. + ''; + }; + dbURL = mkOption { + type = types.nullOr types.str; + default = null; + example = '' + postgres://user:pass@host:5432/dbname + ''; + description = '' + Specify which database to use. + HedgeDoc supports mysql, postgres, sqlite and mssql. + See <link xlink:href="https://sequelize.readthedocs.io/en/v3/"> + https://sequelize.readthedocs.io/en/v3/</link> for more information. + Note: This option overrides <option>db</option>. + ''; + }; + db = mkOption { + type = types.attrs; + default = {}; + example = literalExpression '' + { + dialect = "sqlite"; + storage = "/var/lib/${name}/db.${name}.sqlite"; + } + ''; + description = '' + Specify the configuration for sequelize. + HedgeDoc supports mysql, postgres, sqlite and mssql. + See <link xlink:href="https://sequelize.readthedocs.io/en/v3/"> + https://sequelize.readthedocs.io/en/v3/</link> for more information. + Note: This option overrides <option>db</option>. + ''; + }; + sslKeyPath= mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/hedgedoc.key"; + description = '' + Path to the SSL key. Needed when <option>useSSL</option> is enabled. + ''; + }; + sslCertPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/hedgedoc.crt"; + description = '' + Path to the SSL cert. Needed when <option>useSSL</option> is enabled. + ''; + }; + sslCAPath = mkOption { + type = types.listOf types.str; + default = []; + example = [ "/var/lib/hedgedoc/ca.crt" ]; + description = '' + SSL ca chain. Needed when <option>useSSL</option> is enabled. + ''; + }; + dhParamPath = mkOption { + type = types.nullOr types.str; + default = null; + example = "/var/lib/hedgedoc/dhparam.pem"; + description = '' + Path to the SSL dh params. Needed when <option>useSSL</option> is enabled. + ''; + }; + tmpPath = mkOption { + type = types.str; + default = "/tmp"; + description = '' + Path to the temp directory HedgeDoc should use. + Note that <option>serviceConfig.PrivateTmp</option> is enabled for + the HedgeDoc systemd service by default. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + defaultNotePath = mkOption { + type = types.nullOr types.str; + default = "./public/default.md"; + description = '' + Path to the default Note file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + docsPath = mkOption { + type = types.nullOr types.str; + default = "./public/docs"; + description = '' + Path to the docs directory. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + indexPath = mkOption { + type = types.nullOr types.str; + default = "./public/views/index.ejs"; + description = '' + Path to the index template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + hackmdPath = mkOption { + type = types.nullOr types.str; + default = "./public/views/hackmd.ejs"; + description = '' + Path to the hackmd template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + errorPath = mkOption { + type = types.nullOr types.str; + default = null; + defaultText = literalExpression "./public/views/error.ejs"; + description = '' + Path to the error template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + prettyPath = mkOption { + type = types.nullOr types.str; + default = null; + defaultText = literalExpression "./public/views/pretty.ejs"; + description = '' + Path to the pretty template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + slidePath = mkOption { + type = types.nullOr types.str; + default = null; + defaultText = literalExpression "./public/views/slide.hbs"; + description = '' + Path to the slide template file. + (Non-canonical paths are relative to HedgeDoc's base directory) + ''; + }; + uploadsPath = mkOption { + type = types.str; + default = "${cfg.workDir}/uploads"; + defaultText = literalExpression "/var/lib/${name}/uploads"; + description = '' + Path under which uploaded files are saved. + ''; + }; + sessionName = mkOption { + type = types.str; + default = "connect.sid"; + description = '' + Specify the name of the session cookie. + ''; + }; + sessionSecret = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Specify the secret used to sign the session cookie. + If unset, one will be generated on startup. + ''; + }; + sessionLife = mkOption { + type = types.int; + default = 1209600000; + description = '' + Session life time in milliseconds. + ''; + }; + heartbeatInterval = mkOption { + type = types.int; + default = 5000; + description = '' + Specify the socket.io heartbeat interval. + ''; + }; + heartbeatTimeout = mkOption { + type = types.int; + default = 10000; + description = '' + Specify the socket.io heartbeat timeout. + ''; + }; + documentMaxLength = mkOption { + type = types.int; + default = 100000; + description = '' + Specify the maximum document length. + ''; + }; + email = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable email sign-in. + ''; + }; + allowEmailRegister = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable email registration. + ''; + }; + allowGravatar = mkOption { + type = types.bool; + default = true; + description = '' + Whether to use gravatar as profile picture source. + ''; + }; + imageUploadType = mkOption { + type = types.enum [ "imgur" "s3" "minio" "filesystem" ]; + default = "filesystem"; + description = '' + Specify where to upload images. + ''; + }; + minio = mkOption { + type = types.nullOr (types.submodule { + options = { + accessKey = mkOption { + type = types.str; + description = '' + Minio access key. + ''; + }; + secretKey = mkOption { + type = types.str; + description = '' + Minio secret key. + ''; + }; + endpoint = mkOption { + type = types.str; + description = '' + Minio endpoint. + ''; + }; + port = mkOption { + type = types.int; + default = 9000; + description = '' + Minio listen port. + ''; + }; + secure = mkOption { + type = types.bool; + default = true; + description = '' + Whether to use HTTPS for Minio. + ''; + }; + }; + }); + default = null; + description = "Configure the minio third-party integration."; + }; + s3 = mkOption { + type = types.nullOr (types.submodule { + options = { + accessKeyId = mkOption { + type = types.str; + description = '' + AWS access key id. + ''; + }; + secretAccessKey = mkOption { + type = types.str; + description = '' + AWS access key. + ''; + }; + region = mkOption { + type = types.str; + description = '' + AWS S3 region. + ''; + }; + }; + }); + default = null; + description = "Configure the s3 third-party integration."; + }; + s3bucket = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Specify the bucket name for upload types <literal>s3</literal> and <literal>minio</literal>. + ''; + }; + allowPDFExport = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable PDF exports. + ''; + }; + imgur.clientId = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Imgur API client ID. + ''; + }; + azure = mkOption { + type = types.nullOr (types.submodule { + options = { + connectionString = mkOption { + type = types.str; + description = '' + Azure Blob Storage connection string. + ''; + }; + container = mkOption { + type = types.str; + description = '' + Azure Blob Storage container name. + It will be created if non-existent. + ''; + }; + }; + }); + default = null; + description = "Configure the azure third-party integration."; + }; + oauth2 = mkOption { + type = types.nullOr (types.submodule { + options = { + authorizationURL = mkOption { + type = types.str; + description = '' + Specify the OAuth authorization URL. + ''; + }; + tokenURL = mkOption { + type = types.str; + description = '' + Specify the OAuth token URL. + ''; + }; + baseURL = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the OAuth base URL. + ''; + }; + userProfileURL = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the OAuth userprofile URL. + ''; + }; + userProfileUsernameAttr = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the name of the attribute for the username from the claim. + ''; + }; + userProfileDisplayNameAttr = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the name of the attribute for the display name from the claim. + ''; + }; + userProfileEmailAttr = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the name of the attribute for the email from the claim. + ''; + }; + scope = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the OAuth scope. + ''; + }; + providerName = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the name to be displayed for this strategy. + ''; + }; + rolesClaim = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify the role claim name. + ''; + }; + accessRole = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Specify role which should be included in the ID token roles claim to grant access + ''; + }; + clientID = mkOption { + type = types.str; + description = '' + Specify the OAuth client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Specify the OAuth client secret. + ''; + }; + }; + }); + default = null; + description = "Configure the OAuth integration."; + }; + facebook = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = '' + Facebook API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Facebook API client secret. + ''; + }; + }; + }); + default = null; + description = "Configure the facebook third-party integration"; + }; + twitter = mkOption { + type = types.nullOr (types.submodule { + options = { + consumerKey = mkOption { + type = types.str; + description = '' + Twitter API consumer key. + ''; + }; + consumerSecret = mkOption { + type = types.str; + description = '' + Twitter API consumer secret. + ''; + }; + }; + }); + default = null; + description = "Configure the Twitter third-party integration."; + }; + github = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = '' + GitHub API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Github API client secret. + ''; + }; + }; + }); + default = null; + description = "Configure the GitHub third-party integration."; + }; + gitlab = mkOption { + type = types.nullOr (types.submodule { + options = { + baseURL = mkOption { + type = types.str; + default = ""; + description = '' + GitLab API authentication endpoint. + Only needed for other endpoints than gitlab.com. + ''; + }; + clientID = mkOption { + type = types.str; + description = '' + GitLab API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + GitLab API client secret. + ''; + }; + scope = mkOption { + type = types.enum [ "api" "read_user" ]; + default = "api"; + description = '' + GitLab API requested scope. + GitLab snippet import/export requires api scope. + ''; + }; + }; + }); + default = null; + description = "Configure the GitLab third-party integration."; + }; + mattermost = mkOption { + type = types.nullOr (types.submodule { + options = { + baseURL = mkOption { + type = types.str; + description = '' + Mattermost authentication endpoint. + ''; + }; + clientID = mkOption { + type = types.str; + description = '' + Mattermost API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Mattermost API client secret. + ''; + }; + }; + }); + default = null; + description = "Configure the Mattermost third-party integration."; + }; + dropbox = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = '' + Dropbox API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Dropbox API client secret. + ''; + }; + appKey = mkOption { + type = types.str; + description = '' + Dropbox app key. + ''; + }; + }; + }); + default = null; + description = "Configure the Dropbox third-party integration."; + }; + google = mkOption { + type = types.nullOr (types.submodule { + options = { + clientID = mkOption { + type = types.str; + description = '' + Google API client ID. + ''; + }; + clientSecret = mkOption { + type = types.str; + description = '' + Google API client secret. + ''; + }; + }; + }); + default = null; + description = "Configure the Google third-party integration."; + }; + ldap = mkOption { + type = types.nullOr (types.submodule { + options = { + providerName = mkOption { + type = types.str; + default = ""; + description = '' + Optional name to be displayed at login form, indicating the LDAP provider. + ''; + }; + url = mkOption { + type = types.str; + example = "ldap://localhost"; + description = '' + URL of LDAP server. + ''; + }; + bindDn = mkOption { + type = types.str; + description = '' + Bind DN for LDAP access. + ''; + }; + bindCredentials = mkOption { + type = types.str; + description = '' + Bind credentials for LDAP access. + ''; + }; + searchBase = mkOption { + type = types.str; + example = "o=users,dc=example,dc=com"; + description = '' + LDAP directory to begin search from. + ''; + }; + searchFilter = mkOption { + type = types.str; + example = "(uid={{username}})"; + description = '' + LDAP filter to search with. + ''; + }; + searchAttributes = mkOption { + type = types.listOf types.str; + example = [ "displayName" "mail" ]; + description = '' + LDAP attributes to search with. + ''; + }; + userNameField = mkOption { + type = types.str; + default = ""; + description = '' + LDAP field which is used as the username on HedgeDoc. + By default <option>useridField</option> is used. + ''; + }; + useridField = mkOption { + type = types.str; + example = "uid"; + description = '' + LDAP field which is a unique identifier for users on HedgeDoc. + ''; + }; + tlsca = mkOption { + type = types.str; + example = "server-cert.pem,root.pem"; + description = '' + Root CA for LDAP TLS in PEM format. + ''; + }; + }; + }); + default = null; + description = "Configure the LDAP integration."; + }; + saml = mkOption { + type = types.nullOr (types.submodule { + options = { + idpSsoUrl = mkOption { + type = types.str; + example = "https://idp.example.com/sso"; + description = '' + IdP authentication endpoint. + ''; + }; + idpCert = mkOption { + type = types.path; + example = "/path/to/cert.pem"; + description = '' + Path to IdP certificate file in PEM format. + ''; + }; + issuer = mkOption { + type = types.str; + default = ""; + description = '' + Optional identity of the service provider. + This defaults to the server URL. + ''; + }; + identifierFormat = mkOption { + type = types.str; + default = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + description = '' + Optional name identifier format. + ''; + }; + groupAttribute = mkOption { + type = types.str; + default = ""; + example = "memberOf"; + description = '' + Optional attribute name for group list. + ''; + }; + externalGroups = mkOption { + type = types.listOf types.str; + default = []; + example = [ "Temporary-staff" "External-users" ]; + description = '' + Excluded group names. + ''; + }; + requiredGroups = mkOption { + type = types.listOf types.str; + default = []; + example = [ "Hedgedoc-Users" ]; + description = '' + Required group names. + ''; + }; + attribute = { + id = mkOption { + type = types.str; + default = ""; + description = '' + Attribute map for `id'. + Defaults to `NameID' of SAML response. + ''; + }; + username = mkOption { + type = types.str; + default = ""; + description = '' + Attribute map for `username'. + Defaults to `NameID' of SAML response. + ''; + }; + email = mkOption { + type = types.str; + default = ""; + description = '' + Attribute map for `email'. + Defaults to `NameID' of SAML response if + <option>identifierFormat</option> has + the default value. + ''; + }; + }; + }; + }); + default = null; + description = "Configure the SAML integration."; + }; + }; + + environmentFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/var/lib/hedgedoc/hedgedoc.env"; + description = '' + Environment file as defined in <citerefentry> + <refentrytitle>systemd.exec</refentrytitle><manvolnum>5</manvolnum> + </citerefentry>. + + Secrets may be passed to the service without adding them to the world-readable + Nix store, by specifying placeholder variables as the option value in Nix and + setting these variables accordingly in the environment file. + + <programlisting> + # snippet of HedgeDoc-related config + services.hedgedoc.configuration.dbURL = "postgres://hedgedoc:\''${DB_PASSWORD}@db-host:5432/hedgedocdb"; + services.hedgedoc.configuration.minio.secretKey = "$MINIO_SECRET_KEY"; + </programlisting> + + <programlisting> + # content of the environment file + DB_PASSWORD=verysecretdbpassword + MINIO_SECRET_KEY=verysecretminiokey + </programlisting> + + Note that this file needs to be available on the host on which + <literal>HedgeDoc</literal> is running. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.hedgedoc; + defaultText = literalExpression "pkgs.hedgedoc"; + description = '' + Package that provides HedgeDoc. + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.configuration.db == {} -> ( + cfg.configuration.dbURL != "" && cfg.configuration.dbURL != null + ); + message = "Database configuration for HedgeDoc missing."; } + ]; + users.groups.${name} = {}; + users.users.${name} = { + description = "HedgeDoc service user"; + group = name; + extraGroups = cfg.groups; + home = cfg.workDir; + createHome = true; + isSystemUser = true; + }; + + systemd.services.hedgedoc = { + description = "HedgeDoc Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + preStart = '' + ${pkgs.envsubst}/bin/envsubst \ + -o ${cfg.workDir}/config.json \ + -i ${prettyJSON cfg.configuration} + ''; + serviceConfig = { + WorkingDirectory = cfg.workDir; + ExecStart = "${cfg.package}/bin/hedgedoc"; + EnvironmentFile = mkIf (cfg.environmentFile != null) [ cfg.environmentFile ]; + Environment = [ + "CMD_CONFIG_FILE=${cfg.workDir}/config.json" + "NODE_ENV=production" + ]; + Restart = "always"; + User = name; + PrivateTmp = true; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/hledger-web.nix b/nixos/modules/services/web-apps/hledger-web.nix new file mode 100644 index 00000000000..4f6a34e6d2f --- /dev/null +++ b/nixos/modules/services/web-apps/hledger-web.nix @@ -0,0 +1,142 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.hledger-web; +in { + options.services.hledger-web = { + + enable = mkEnableOption "hledger-web service"; + + serveApi = mkEnableOption "Serve only the JSON web API, without the web UI."; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = '' + Address to listen on. + ''; + }; + + port = mkOption { + type = types.port; + default = 5000; + example = 80; + description = '' + Port to listen on. + ''; + }; + + capabilities = { + view = mkOption { + type = types.bool; + default = true; + description = '' + Enable the view capability. + ''; + }; + add = mkOption { + type = types.bool; + default = false; + description = '' + Enable the add capability. + ''; + }; + manage = mkOption { + type = types.bool; + default = false; + description = '' + Enable the manage capability. + ''; + }; + }; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/hledger-web"; + description = '' + Path the service has access to. If left as the default value this + directory will automatically be created before the hledger-web server + starts, otherwise the sysadmin is responsible for ensuring the + directory exists with appropriate ownership and permissions. + ''; + }; + + journalFiles = mkOption { + type = types.listOf types.str; + default = [ ".hledger.journal" ]; + description = '' + Paths to journal files relative to <option>services.hledger-web.stateDir</option>. + ''; + }; + + baseUrl = mkOption { + type = with types; nullOr str; + default = null; + example = "https://example.org"; + description = '' + Base URL, when sharing over a network. + ''; + }; + + extraOptions = mkOption { + type = types.listOf types.str; + default = []; + example = [ "--forecast" ]; + description = '' + Extra command line arguments to pass to hledger-web. + ''; + }; + + }; + + config = mkIf cfg.enable { + + users.users.hledger = { + name = "hledger"; + group = "hledger"; + isSystemUser = true; + home = cfg.stateDir; + useDefaultShell = true; + }; + + users.groups.hledger = {}; + + systemd.services.hledger-web = let + capabilityString = with cfg.capabilities; concatStringsSep "," ( + (optional view "view") + ++ (optional add "add") + ++ (optional manage "manage") + ); + serverArgs = with cfg; escapeShellArgs ([ + "--serve" + "--host=${host}" + "--port=${toString port}" + "--capabilities=${capabilityString}" + (optionalString (cfg.baseUrl != null) "--base-url=${cfg.baseUrl}") + (optionalString (cfg.serveApi) "--serve-api") + ] ++ (map (f: "--file=${stateDir}/${f}") cfg.journalFiles) + ++ extraOptions); + in { + description = "hledger-web - web-app for the hledger accounting tool."; + documentation = [ "https://hledger.org/hledger-web.html" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + serviceConfig = mkMerge [ + { + ExecStart = "${pkgs.hledger-web}/bin/hledger-web ${serverArgs}"; + Restart = "always"; + WorkingDirectory = cfg.stateDir; + User = "hledger"; + Group = "hledger"; + PrivateTmp = true; + } + (mkIf (cfg.stateDir == "/var/lib/hledger-web") { + StateDirectory = "hledger-web"; + }) + ]; + }; + + }; + + meta.maintainers = with lib.maintainers; [ marijanp erictapen ]; +} diff --git a/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix b/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix new file mode 100644 index 00000000000..b9761061aaa --- /dev/null +++ b/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix @@ -0,0 +1,262 @@ +{ config, lib, pkgs, ... }: with lib; let + cfg = config.services.icingaweb2; + fpm = config.services.phpfpm.pools.${poolName}; + poolName = "icingaweb2"; + + defaultConfig = { + global = { + module_path = "${pkgs.icingaweb2}/modules"; + }; + }; +in { + meta.maintainers = with maintainers; [ das_j ]; + + options.services.icingaweb2 = with types; { + enable = mkEnableOption "the icingaweb2 web interface"; + + pool = mkOption { + type = str; + default = poolName; + description = '' + Name of existing PHP-FPM pool that is used to run Icingaweb2. + If not specified, a pool will automatically created with default values. + ''; + }; + + libraryPaths = mkOption { + type = attrsOf package; + default = { }; + description = '' + Libraries to add to the Icingaweb2 library path. + The name of the attribute is the name of the library, the value + is the package to add. + ''; + }; + + virtualHost = mkOption { + type = nullOr str; + default = "icingaweb2"; + description = '' + Name of the nginx virtualhost to use and setup. If null, no virtualhost is set up. + ''; + }; + + timezone = mkOption { + type = str; + default = "UTC"; + example = "Europe/Berlin"; + description = "PHP-compliant timezone specification"; + }; + + modules = { + doc.enable = mkEnableOption "the icingaweb2 doc module"; + migrate.enable = mkEnableOption "the icingaweb2 migrate module"; + setup.enable = mkEnableOption "the icingaweb2 setup module"; + test.enable = mkEnableOption "the icingaweb2 test module"; + translation.enable = mkEnableOption "the icingaweb2 translation module"; + }; + + modulePackages = mkOption { + type = attrsOf package; + default = {}; + example = literalExpression '' + { + "snow" = icingaweb2Modules.theme-snow; + } + ''; + description = '' + Name-package attrset of Icingaweb 2 modules packages to enable. + + If you enable modules manually (e.g. via the web ui), they will not be touched. + ''; + }; + + generalConfig = mkOption { + type = nullOr attrs; + default = null; + example = { + general = { + showStacktraces = 1; + config_resource = "icingaweb_db"; + }; + logging = { + log = "syslog"; + level = "CRITICAL"; + }; + }; + description = '' + config.ini contents. + Will automatically be converted to a .ini file. + If you don't set global.module_path, the module will take care of it. + + If the value is null, no config.ini is created and you can + modify it manually (e.g. via the web interface). + Note that you need to update module_path manually. + ''; + }; + + resources = mkOption { + type = nullOr attrs; + default = null; + example = { + icingaweb_db = { + type = "db"; + db = "mysql"; + host = "localhost"; + username = "icingaweb2"; + password = "icingaweb2"; + dbname = "icingaweb2"; + }; + }; + description = '' + resources.ini contents. + Will automatically be converted to a .ini file. + + If the value is null, no resources.ini is created and you can + modify it manually (e.g. via the web interface). + Note that if you set passwords here, they will go into the nix store. + ''; + }; + + authentications = mkOption { + type = nullOr attrs; + default = null; + example = { + icingaweb = { + backend = "db"; + resource = "icingaweb_db"; + }; + }; + description = '' + authentication.ini contents. + Will automatically be converted to a .ini file. + + If the value is null, no authentication.ini is created and you can + modify it manually (e.g. via the web interface). + ''; + }; + + groupBackends = mkOption { + type = nullOr attrs; + default = null; + example = { + icingaweb = { + backend = "db"; + resource = "icingaweb_db"; + }; + }; + description = '' + groups.ini contents. + Will automatically be converted to a .ini file. + + If the value is null, no groups.ini is created and you can + modify it manually (e.g. via the web interface). + ''; + }; + + roles = mkOption { + type = nullOr attrs; + default = null; + example = { + Administrators = { + users = "admin"; + permissions = "*"; + }; + }; + description = '' + roles.ini contents. + Will automatically be converted to a .ini file. + + If the value is null, no roles.ini is created and you can + modify it manually (e.g. via the web interface). + ''; + }; + }; + + config = mkIf cfg.enable { + services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") { + ${poolName} = { + user = "icingaweb2"; + phpEnv = { + ICINGAWEB_LIBDIR = toString (pkgs.linkFarm "icingaweb2-libdir" (mapAttrsToList (name: path: { inherit name path; }) cfg.libraryPaths)); + }; + phpPackage = pkgs.php.withExtensions ({ enabled, all }: [ all.imagick ] ++ enabled); + phpOptions = '' + date.timezone = "${cfg.timezone}" + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0600"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 10; + }; + }; + }; + + services.icingaweb2.libraryPaths = { + ipl = pkgs.icingaweb2-ipl; + thirdparty = pkgs.icingaweb2-thirdparty; + }; + + systemd.services."phpfpm-${poolName}".serviceConfig.ReadWritePaths = [ "/etc/icingaweb2" ]; + + services.nginx = { + enable = true; + virtualHosts = mkIf (cfg.virtualHost != null) { + ${cfg.virtualHost} = { + root = "${pkgs.icingaweb2}/public"; + + extraConfig = '' + index index.php; + try_files $1 $uri $uri/ /index.php$is_args$args; + ''; + + locations."~ ..*/.*.php$".extraConfig = '' + return 403; + ''; + + locations."~ ^/index.php(.*)$".extraConfig = '' + fastcgi_intercept_errors on; + fastcgi_index index.php; + include ${config.services.nginx.package}/conf/fastcgi.conf; + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${fpm.socket}; + fastcgi_param SCRIPT_FILENAME ${pkgs.icingaweb2}/public/index.php; + ''; + }; + }; + }; + + # /etc/icingaweb2 + environment.etc = let + doModule = name: optionalAttrs (cfg.modules.${name}.enable) { "icingaweb2/enabledModules/${name}".source = "${pkgs.icingaweb2}/modules/${name}"; }; + in {} + # Module packages + // (mapAttrs' (k: v: nameValuePair "icingaweb2/enabledModules/${k}" { source = v; }) cfg.modulePackages) + # Built-in modules + // doModule "doc" + // doModule "migrate" + // doModule "setup" + // doModule "test" + // doModule "translation" + # Configs + // optionalAttrs (cfg.generalConfig != null) { "icingaweb2/config.ini".text = generators.toINI {} (defaultConfig // cfg.generalConfig); } + // optionalAttrs (cfg.resources != null) { "icingaweb2/resources.ini".text = generators.toINI {} cfg.resources; } + // optionalAttrs (cfg.authentications != null) { "icingaweb2/authentication.ini".text = generators.toINI {} cfg.authentications; } + // optionalAttrs (cfg.groupBackends != null) { "icingaweb2/groups.ini".text = generators.toINI {} cfg.groupBackends; } + // optionalAttrs (cfg.roles != null) { "icingaweb2/roles.ini".text = generators.toINI {} cfg.roles; }; + + # User and group + users.groups.icingaweb2 = {}; + users.users.icingaweb2 = { + description = "Icingaweb2 service user"; + group = "icingaweb2"; + isSystemUser = true; + }; + }; +} diff --git a/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix b/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix new file mode 100644 index 00000000000..e9c1d4ffe5e --- /dev/null +++ b/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix @@ -0,0 +1,157 @@ +{ config, lib, pkgs, ... }: with lib; let + cfg = config.services.icingaweb2.modules.monitoring; + + configIni = '' + [security] + protected_customvars = "${concatStringsSep "," cfg.generalConfig.protectedVars}" + ''; + + backendsIni = let + formatBool = b: if b then "1" else "0"; + in concatStringsSep "\n" (mapAttrsToList (name: config: '' + [${name}] + type = "ido" + resource = "${config.resource}" + disabled = "${formatBool config.disabled}" + '') cfg.backends); + + transportsIni = concatStringsSep "\n" (mapAttrsToList (name: config: '' + [${name}] + type = "${config.type}" + ${optionalString (config.instance != null) ''instance = "${config.instance}"''} + ${optionalString (config.type == "local" || config.type == "remote") ''path = "${config.path}"''} + ${optionalString (config.type != "local") '' + host = "${config.host}" + ${optionalString (config.port != null) ''port = "${toString config.port}"''} + user${optionalString (config.type == "api") "name"} = "${config.username}" + ''} + ${optionalString (config.type == "api") ''password = "${config.password}"''} + ${optionalString (config.type == "remote") ''resource = "${config.resource}"''} + '') cfg.transports); + +in { + options.services.icingaweb2.modules.monitoring = with types; { + enable = mkOption { + type = bool; + default = true; + description = "Whether to enable the icingaweb2 monitoring module."; + }; + + generalConfig = { + mutable = mkOption { + type = bool; + default = false; + description = "Make config.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + protectedVars = mkOption { + type = listOf str; + default = [ "*pw*" "*pass*" "community" ]; + description = "List of string patterns for custom variables which should be excluded from user’s view."; + }; + }; + + mutableBackends = mkOption { + type = bool; + default = false; + description = "Make backends.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + backends = mkOption { + default = { icinga = { resource = "icinga_ido"; }; }; + description = "Monitoring backends to define"; + type = attrsOf (submodule ({ name, ... }: { + options = { + name = mkOption { + visible = false; + default = name; + type = str; + description = "Name of this backend"; + }; + + resource = mkOption { + type = str; + description = "Name of the IDO resource"; + }; + + disabled = mkOption { + type = bool; + default = false; + description = "Disable this backend"; + }; + }; + })); + }; + + mutableTransports = mkOption { + type = bool; + default = true; + description = "Make commandtransports.ini of the monitoring module mutable (e.g. via the web interface)."; + }; + + transports = mkOption { + default = {}; + description = "Command transports to define"; + type = attrsOf (submodule ({ name, ... }: { + options = { + name = mkOption { + visible = false; + default = name; + type = str; + description = "Name of this transport"; + }; + + type = mkOption { + type = enum [ "api" "local" "remote" ]; + default = "api"; + description = "Type of this transport"; + }; + + instance = mkOption { + type = nullOr str; + default = null; + description = "Assign a icinga instance to this transport"; + }; + + path = mkOption { + type = str; + description = "Path to the socket for local or remote transports"; + }; + + host = mkOption { + type = str; + description = "Host for the api or remote transport"; + }; + + port = mkOption { + type = nullOr str; + default = null; + description = "Port to connect to for the api or remote transport"; + }; + + username = mkOption { + type = str; + description = "Username for the api or remote transport"; + }; + + password = mkOption { + type = str; + description = "Password for the api transport"; + }; + + resource = mkOption { + type = str; + description = "SSH identity resource for the remote transport"; + }; + }; + })); + }; + }; + + config = mkIf (config.services.icingaweb2.enable && cfg.enable) { + environment.etc = { "icingaweb2/enabledModules/monitoring" = { source = "${pkgs.icingaweb2}/modules/monitoring"; }; } + // optionalAttrs (!cfg.generalConfig.mutable) { "icingaweb2/modules/monitoring/config.ini".text = configIni; } + // optionalAttrs (!cfg.mutableBackends) { "icingaweb2/modules/monitoring/backends.ini".text = backendsIni; } + // optionalAttrs (!cfg.mutableTransports) { "icingaweb2/modules/monitoring/commandtransports.ini".text = transportsIni; }; + }; +} diff --git a/nixos/modules/services/web-apps/ihatemoney/default.nix b/nixos/modules/services/web-apps/ihatemoney/default.nix new file mode 100644 index 00000000000..ad314c885ba --- /dev/null +++ b/nixos/modules/services/web-apps/ihatemoney/default.nix @@ -0,0 +1,153 @@ +{ config, pkgs, lib, ... }: +with lib; +let + cfg = config.services.ihatemoney; + user = "ihatemoney"; + group = "ihatemoney"; + db = "ihatemoney"; + python3 = config.services.uwsgi.package.python3; + pkg = python3.pkgs.ihatemoney; + toBool = x: if x then "True" else "False"; + configFile = pkgs.writeText "ihatemoney.cfg" '' + from secrets import token_hex + # load a persistent secret key + SECRET_KEY_FILE = "/var/lib/ihatemoney/secret_key" + SECRET_KEY = "" + try: + with open(SECRET_KEY_FILE) as f: + SECRET_KEY = f.read() + except FileNotFoundError: + pass + if not SECRET_KEY: + print("ihatemoney: generating a new secret key") + SECRET_KEY = token_hex(50) + with open(SECRET_KEY_FILE, "w") as f: + f.write(SECRET_KEY) + del token_hex + del SECRET_KEY_FILE + + # "normal" configuration + DEBUG = False + SQLALCHEMY_DATABASE_URI = '${ + if cfg.backend == "sqlite" + then "sqlite:////var/lib/ihatemoney/ihatemoney.sqlite" + else "postgresql:///${db}"}' + SQLALCHEMY_TRACK_MODIFICATIONS = False + MAIL_DEFAULT_SENDER = (r"${cfg.defaultSender.name}", r"${cfg.defaultSender.email}") + ACTIVATE_DEMO_PROJECT = ${toBool cfg.enableDemoProject} + ADMIN_PASSWORD = r"${toString cfg.adminHashedPassword /*toString null == ""*/}" + ALLOW_PUBLIC_PROJECT_CREATION = ${toBool cfg.enablePublicProjectCreation} + ACTIVATE_ADMIN_DASHBOARD = ${toBool cfg.enableAdminDashboard} + SESSION_COOKIE_SECURE = ${toBool cfg.secureCookie} + ENABLE_CAPTCHA = ${toBool cfg.enableCaptcha} + LEGAL_LINK = r"${toString cfg.legalLink}" + + ${cfg.extraConfig} + ''; +in + { + options.services.ihatemoney = { + enable = mkEnableOption "ihatemoney webapp. Note that this will set uwsgi to emperor mode"; + backend = mkOption { + type = types.enum [ "sqlite" "postgresql" ]; + default = "sqlite"; + description = '' + The database engine to use for ihatemoney. + If <literal>postgresql</literal> is selected, then a database called + <literal>${db}</literal> will be created. If you disable this option, + it will however not be removed. + ''; + }; + adminHashedPassword = mkOption { + type = types.nullOr types.str; + default = null; + description = "The hashed password of the administrator. To obtain it, run <literal>ihatemoney generate_password_hash</literal>"; + }; + uwsgiConfig = mkOption { + type = types.attrs; + example = { + http = ":8000"; + }; + description = "Additionnal configuration of the UWSGI vassal running ihatemoney. It should notably specify on which interfaces and ports the vassal should listen."; + }; + defaultSender = { + name = mkOption { + type = types.str; + default = "Budget manager"; + description = "The display name of the sender of ihatemoney emails"; + }; + email = mkOption { + type = types.str; + default = "ihatemoney@${config.networking.hostName}"; + defaultText = literalExpression ''"ihatemoney@''${config.networking.hostName}"''; + description = "The email of the sender of ihatemoney emails"; + }; + }; + secureCookie = mkOption { + type = types.bool; + default = true; + description = "Use secure cookies. Disable this when ihatemoney is served via http instead of https"; + }; + enableDemoProject = mkEnableOption "access to the demo project in ihatemoney"; + enablePublicProjectCreation = mkEnableOption "permission to create projects in ihatemoney by anyone"; + enableAdminDashboard = mkEnableOption "ihatemoney admin dashboard"; + enableCaptcha = mkEnableOption "a simplistic captcha for some forms"; + legalLink = mkOption { + type = types.nullOr types.str; + default = null; + description = "The URL to a page explaining legal statements about your service, eg. GDPR-related information."; + }; + extraConfig = mkOption { + type = types.str; + default = ""; + description = "Extra configuration appended to ihatemoney's configuration file. It is a python file, so pay attention to indentation."; + }; + }; + config = mkIf cfg.enable { + services.postgresql = mkIf (cfg.backend == "postgresql") { + enable = true; + ensureDatabases = [ db ]; + ensureUsers = [ { + name = user; + ensurePermissions = { + "DATABASE ${db}" = "ALL PRIVILEGES"; + }; + } ]; + }; + systemd.services.postgresql = mkIf (cfg.backend == "postgresql") { + wantedBy = [ "uwsgi.service" ]; + before = [ "uwsgi.service" ]; + }; + systemd.tmpfiles.rules = [ + "d /var/lib/ihatemoney 770 ${user} ${group}" + ]; + users = { + users.${user} = { + isSystemUser = true; + inherit group; + }; + groups.${group} = {}; + }; + services.uwsgi = { + enable = true; + plugins = [ "python3" ]; + instance = { + type = "emperor"; + vassals.ihatemoney = { + type = "normal"; + strict = true; + immediate-uid = user; + immediate-gid = group; + # apparently flask uses threads: https://github.com/spiral-project/ihatemoney/commit/c7815e48781b6d3a457eaff1808d179402558f8c + enable-threads = true; + module = "wsgi:application"; + chdir = "${pkg}/${pkg.pythonModule.sitePackages}/ihatemoney"; + env = [ "IHATEMONEY_SETTINGS_FILE_PATH=${configFile}" ]; + pythonPackages = self: [ self.ihatemoney ]; + } // cfg.uwsgiConfig; + }; + }; + }; + } + + diff --git a/nixos/modules/services/web-apps/invidious.nix b/nixos/modules/services/web-apps/invidious.nix new file mode 100644 index 00000000000..10b30bf1fd1 --- /dev/null +++ b/nixos/modules/services/web-apps/invidious.nix @@ -0,0 +1,264 @@ +{ lib, config, pkgs, options, ... }: +let + cfg = config.services.invidious; + # To allow injecting secrets with jq, json (instead of yaml) is used + settingsFormat = pkgs.formats.json { }; + inherit (lib) types; + + settingsFile = settingsFormat.generate "invidious-settings" cfg.settings; + + serviceConfig = { + systemd.services.invidious = { + description = "Invidious (An alternative YouTube front-end)"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + script = + let + jqFilter = "." + + lib.optionalString (cfg.database.host != null) "[0].db.password = \"'\"'\"$(cat ${lib.escapeShellArg cfg.database.passwordFile})\"'\"'\"" + + " | .[0]" + + lib.optionalString (cfg.extraSettingsFile != null) " * .[1]"; + jqFiles = [ settingsFile ] ++ lib.optional (cfg.extraSettingsFile != null) cfg.extraSettingsFile; + in + '' + export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s "${jqFilter}" ${lib.escapeShellArgs jqFiles})" + exec ${cfg.package}/bin/invidious + ''; + + serviceConfig = { + RestartSec = "2s"; + DynamicUser = true; + + CapabilityBoundingSet = ""; + PrivateDevices = true; + PrivateUsers = true; + ProtectHome = true; + ProtectKernelLogs = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + }; + }; + + services.invidious.settings = { + inherit (cfg) port; + + # Automatically initialises and migrates the database if necessary + check_tables = true; + + db = { + user = lib.mkDefault "kemal"; + dbname = lib.mkDefault "invidious"; + port = cfg.database.port; + # Blank for unix sockets, see + # https://github.com/will/crystal-pg/blob/1548bb255210/src/pq/conninfo.cr#L100-L108 + host = if cfg.database.host == null then "" else cfg.database.host; + # Not needed because peer authentication is enabled + password = lib.mkIf (cfg.database.host == null) ""; + }; + } // (lib.optionalAttrs (cfg.domain != null) { + inherit (cfg) domain; + }); + + assertions = [{ + assertion = cfg.database.host != null -> cfg.database.passwordFile != null; + message = "If database host isn't null, database password needs to be set"; + }]; + }; + + # Settings necessary for running with an automatically managed local database + localDatabaseConfig = lib.mkIf cfg.database.createLocally { + # Default to using the local database if we create it + services.invidious.database.host = lib.mkDefault null; + + services.postgresql = { + enable = true; + ensureDatabases = lib.singleton cfg.settings.db.dbname; + ensureUsers = lib.singleton { + name = cfg.settings.db.user; + ensurePermissions = { + "DATABASE ${cfg.settings.db.dbname}" = "ALL PRIVILEGES"; + }; + }; + # This is only needed because the unix user invidious isn't the same as + # the database user. This tells postgres to map one to the other. + identMap = '' + invidious invidious ${cfg.settings.db.user} + ''; + # And this specifically enables peer authentication for only this + # database, which allows passwordless authentication over the postgres + # unix socket for the user map given above. + authentication = '' + local ${cfg.settings.db.dbname} ${cfg.settings.db.user} peer map=invidious + ''; + }; + + systemd.services.invidious-db-clean = { + description = "Invidious database cleanup"; + documentation = [ "https://docs.invidious.io/Database-Information-and-Maintenance.md" ]; + startAt = lib.mkDefault "weekly"; + path = [ config.services.postgresql.package ]; + script = '' + psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "DELETE FROM nonces * WHERE expire < current_timestamp" + psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "TRUNCATE TABLE videos" + ''; + serviceConfig = { + DynamicUser = true; + User = "invidious"; + }; + }; + + systemd.services.invidious = { + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + + serviceConfig = { + User = "invidious"; + }; + }; + }; + + nginxConfig = lib.mkIf cfg.nginx.enable { + services.invidious.settings = { + https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL; + external_port = 80; + }; + + services.nginx = { + enable = true; + virtualHosts.${cfg.domain} = { + locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}"; + + enableACME = lib.mkDefault true; + forceSSL = lib.mkDefault true; + }; + }; + + assertions = [{ + assertion = cfg.domain != null; + message = "To use services.invidious.nginx, you need to set services.invidious.domain"; + }]; + }; +in +{ + options.services.invidious = { + enable = lib.mkEnableOption "Invidious"; + + package = lib.mkOption { + type = types.package; + default = pkgs.invidious; + defaultText = "pkgs.invidious"; + description = "The Invidious package to use."; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + default = { }; + description = '' + The settings Invidious should use. + + See <link xlink:href="https://github.com/iv-org/invidious/blob/master/config/config.example.yml">config.example.yml</link> for a list of all possible options. + ''; + }; + + extraSettingsFile = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = '' + A file including Invidious settings. + + It gets merged with the setttings specified in <option>services.invidious.settings</option> + and can be used to store secrets like <literal>hmac_key</literal> outside of the nix store. + ''; + }; + + # This needs to be outside of settings to avoid infinite recursion + # (determining if nginx should be enabled and therefore the settings + # modified). + domain = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The FQDN Invidious is reachable on. + + This is used to configure nginx and for building absolute URLs. + ''; + }; + + port = lib.mkOption { + type = types.port; + # Default from https://docs.invidious.io/Configuration.md + default = 3000; + description = '' + The port Invidious should listen on. + + To allow access from outside, + you can use either <option>services.invidious.nginx</option> + or add <literal>config.services.invidious.port</literal> to <option>networking.firewall.allowedTCPPorts</option>. + ''; + }; + + database = { + createLocally = lib.mkOption { + type = types.bool; + default = true; + description = '' + Whether to create a local database with PostgreSQL. + ''; + }; + + host = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The database host Invidious should use. + + If <literal>null</literal>, the local unix socket is used. Otherwise + TCP is used. + ''; + }; + + port = lib.mkOption { + type = types.port; + default = options.services.postgresql.port.default; + defaultText = lib.literalExpression "options.services.postgresql.port.default"; + description = '' + The port of the database Invidious should use. + + Defaults to the the default postgresql port. + ''; + }; + + passwordFile = lib.mkOption { + type = types.nullOr types.str; + apply = lib.mapNullable toString; + default = null; + description = '' + Path to file containing the database password. + ''; + }; + }; + + nginx.enable = lib.mkOption { + type = types.bool; + default = false; + description = '' + Whether to configure nginx as a reverse proxy for Invidious. + + It serves it under the domain specified in <option>services.invidious.settings.domain</option> with enabled TLS and ACME. + Further configuration can be done through <option>services.nginx.virtualHosts.''${config.services.invidious.settings.domain}.*</option>, + which can also be used to disable AMCE and TLS. + ''; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + serviceConfig + localDatabaseConfig + nginxConfig + ]); +} diff --git a/nixos/modules/services/web-apps/invoiceplane.nix b/nixos/modules/services/web-apps/invoiceplane.nix new file mode 100644 index 00000000000..095eec36dec --- /dev/null +++ b/nixos/modules/services/web-apps/invoiceplane.nix @@ -0,0 +1,305 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.invoiceplane; + eachSite = cfg.sites; + user = "invoiceplane"; + webserver = config.services.${cfg.webserver}; + + invoiceplane-config = hostName: cfg: pkgs.writeText "ipconfig.php" '' + IP_URL=http://${hostName} + ENABLE_DEBUG=false + DISABLE_SETUP=false + REMOVE_INDEXPHP=false + DB_HOSTNAME=${cfg.database.host} + DB_USERNAME=${cfg.database.user} + # NOTE: file_get_contents adds newline at the end of returned string + DB_PASSWORD=${if cfg.database.passwordFile == null then "" else "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")"} + DB_DATABASE=${cfg.database.name} + DB_PORT=${toString cfg.database.port} + SESS_EXPIRATION=864000 + ENABLE_INVOICE_DELETION=false + DISABLE_READ_ONLY=false + ENCRYPTION_KEY= + ENCRYPTION_CIPHER=AES-256 + SETUP_COMPLETED=false + ''; + + extraConfig = hostName: cfg: pkgs.writeText "extraConfig.php" '' + ${toString cfg.extraConfig} + ''; + + pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { + pname = "invoiceplane-${hostName}"; + version = src.version; + src = pkgs.invoiceplane; + + patchPhase = '' + # Patch index.php file to load additional config file + substituteInPlace index.php \ + --replace "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = new \Dotenv\Dotenv(__DIR__, 'extraConfig.php'); \$dotenv->load();"; + ''; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + # symlink uploads and log directories + rm -r $out/uploads $out/application/logs $out/vendor/mpdf/mpdf/tmp + ln -sf ${cfg.stateDir}/uploads $out/ + ln -sf ${cfg.stateDir}/logs $out/application/ + ln -sf ${cfg.stateDir}/tmp $out/vendor/mpdf/mpdf/ + + # symlink the InvoicePlane config + ln -s ${cfg.stateDir}/ipconfig.php $out/ipconfig.php + + # symlink the extraConfig file + ln -s ${extraConfig hostName cfg} $out/extraConfig.php + + # symlink additional templates + ${concatMapStringsSep "\n" (template: "cp -r ${template}/. $out/application/views/invoice_templates/pdf/") cfg.invoiceTemplates} + ''; + }; + + siteOpts = { lib, name, ... }: + { + options = { + + enable = mkEnableOption "InvoicePlane web application"; + + stateDir = mkOption { + type = types.path; + default = "/var/lib/invoiceplane/${name}"; + description = '' + This directory is used for uploads of attachements and cache. + The directory passed here is automatically created and permissions + adjusted as required. + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "invoiceplane"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "invoiceplane"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/invoiceplane-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + invoiceTemplates = mkOption { + type = types.listOf types.path; + default = []; + description = '' + List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory. + <note><para>These templates need to be packaged before use, see example.</para></note> + ''; + example = literalExpression '' + let + # Let's package an example template + template-vtdirektmarketing = pkgs.stdenv.mkDerivation { + name = "vtdirektmarketing"; + # Download the template from a public repository + src = pkgs.fetchgit { + url = "https://git.project-insanity.org/onny/invoiceplane-vtdirektmarketing.git"; + sha256 = "1hh0q7wzsh8v8x03i82p6qrgbxr4v5fb05xylyrpp975l8axyg2z"; + }; + sourceRoot = "."; + # Installing simply means copying template php file to the output directory + installPhase = "" + mkdir -p $out + cp invoiceplane-vtdirektmarketing/vtdirektmarketing.php $out/ + ""; + }; + # And then pass this package to the template list like this: + in [ template-vtdirektmarketing ] + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the InvoicePlane PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.nullOr types.lines; + default = null; + example = '' + SETUP_COMPLETED=true + DISABLE_SETUP=true + IP_URL=https://invoice.example.com + ''; + description = '' + InvoicePlane configuration. Refer to + <link xlink:href="https://github.com/InvoicePlane/InvoicePlane/blob/master/ipconfig.php.example"/> + for details on supported values. + ''; + }; + + }; + + }; +in +{ + # interface + options = { + services.invoiceplane = mkOption { + type = types.submodule { + + options.sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = "Specification of one or more WordPress sites to serve"; + }; + + options.webserver = mkOption { + type = types.enum [ "caddy" ]; + default = "caddy"; + description = '' + Which webserver to use for virtual host management. Currently only + caddy is supported. + ''; + }; + }; + default = {}; + description = "InvoicePlane configuration."; + }; + + }; + + # implementation + config = mkIf (eachSite != {}) (mkMerge [{ + + assertions = flatten (mapAttrsToList (hostName: cfg: + [{ assertion = cfg.database.createLocally -> cfg.database.user == user; + message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = ''services.invoiceplane.sites."${hostName}".database.passwordFile cannot be specified if services.invoiceplane.sites."${hostName}".database.createLocally is set to true.''; + }] + ) eachSite); + + services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; + ensureUsers = mapAttrsToList (hostName: cfg: + { name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + } + ) eachSite; + }; + + services.phpfpm = { + phpPackage = pkgs.php74; + pools = mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-${hostName}" { + inherit user; + group = webserver.group; + settings = { + "listen.owner" = webserver.user; + "listen.group" = webserver.group; + } // cfg.poolConfig; + } + )) eachSite; + }; + + } + + { + systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [ + "d ${cfg.stateDir} 0750 ${user} ${webserver.group} - -" + "f ${cfg.stateDir}/ipconfig.php 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/logs 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/archive 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/customer_files 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/temp 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/uploads/temp/mpdf 0750 ${user} ${webserver.group} - -" + "d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -" + ]) eachSite); + + systemd.services.invoiceplane-config = { + serviceConfig.Type = "oneshot"; + script = concatStrings (mapAttrsToList (hostName: cfg: + '' + mkdir -p ${cfg.stateDir}/logs \ + ${cfg.stateDir}/uploads + if ! grep -q IP_URL "${cfg.stateDir}/ipconfig.php"; then + cp "${invoiceplane-config hostName cfg}" "${cfg.stateDir}/ipconfig.php" + fi + '') eachSite); + wantedBy = [ "multi-user.target" ]; + }; + + users.users.${user} = { + group = webserver.group; + isSystemUser = true; + }; + } + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' (hostName: cfg: ( + nameValuePair "http://${hostName}" { + extraConfig = '' + root * ${pkg hostName cfg} + file_server + + php_fastcgi unix/${config.services.phpfpm.pools."invoiceplane-${hostName}".socket} + ''; + } + )) eachSite; + }; + }) + + + ]); +} + diff --git a/nixos/modules/services/web-apps/isso.nix b/nixos/modules/services/web-apps/isso.nix new file mode 100644 index 00000000000..4c01781a6a2 --- /dev/null +++ b/nixos/modules/services/web-apps/isso.nix @@ -0,0 +1,69 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkEnableOption mkIf mkOption types literalExpression; + + cfg = config.services.isso; + + settingsFormat = pkgs.formats.ini { }; + configFile = settingsFormat.generate "isso.conf" cfg.settings; +in { + + options = { + services.isso = { + enable = mkEnableOption '' + A commenting server similar to Disqus. + + Note: The application's author suppose to run isso behind a reverse proxy. + The embedded solution offered by NixOS is also only suitable for small installations + below 20 requests per second. + ''; + + settings = mkOption { + description = '' + Configuration for <package>isso</package>. + + See <link xlink:href="https://posativ.org/isso/docs/configuration/server/">Isso Server Configuration</link> + for supported values. + ''; + + type = types.submodule { + freeformType = settingsFormat.type; + }; + + example = literalExpression '' + { + general = { + host = "http://localhost"; + }; + } + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.isso.settings.general.dbpath = lib.mkDefault "/var/lib/isso/comments.db"; + + systemd.services.isso = { + description = "isso, a commenting server similar to Disqus"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + User = "isso"; + Group = "isso"; + + DynamicUser = true; + + StateDirectory = "isso"; + + ExecStart = '' + ${pkgs.isso}/bin/isso -c ${configFile} + ''; + + Restart = "on-failure"; + RestartSec = 1; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/jirafeau.nix b/nixos/modules/services/web-apps/jirafeau.nix new file mode 100644 index 00000000000..328c61c8e64 --- /dev/null +++ b/nixos/modules/services/web-apps/jirafeau.nix @@ -0,0 +1,173 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.jirafeau; + + group = config.services.nginx.group; + user = config.services.nginx.user; + + withTrailingSlash = str: if hasSuffix "/" str then str else "${str}/"; + + localConfig = pkgs.writeText "config.local.php" '' + <?php + $cfg['admin_password'] = '${cfg.adminPasswordSha256}'; + $cfg['web_root'] = 'http://${withTrailingSlash cfg.hostName}'; + $cfg['var_root'] = '${withTrailingSlash cfg.dataDir}'; + $cfg['maximal_upload_size'] = ${builtins.toString cfg.maxUploadSizeMegabytes}; + $cfg['installation_done'] = true; + + ${cfg.extraConfig} + ''; +in +{ + options.services.jirafeau = { + adminPasswordSha256 = mkOption { + type = types.str; + default = ""; + description = '' + SHA-256 of the desired administration password. Leave blank/unset for no password. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/jirafeau/data/"; + description = "Location of Jirafeau storage directory."; + }; + + enable = mkEnableOption "Jirafeau file upload application."; + + extraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + $cfg['style'] = 'courgette'; + $cfg['organisation'] = 'ACME'; + ''; + description = let + documentationLink = + "https://gitlab.com/mojo42/Jirafeau/-/blob/${cfg.package.version}/lib/config.original.php"; + in + '' + Jirefeau configuration. Refer to <link xlink:href="${documentationLink}"/> for supported + values. + ''; + }; + + hostName = mkOption { + type = types.str; + default = "localhost"; + description = "URL of instance. Must have trailing slash."; + }; + + maxUploadSizeMegabytes = mkOption { + type = types.int; + default = 0; + description = "Maximum upload size of accepted files."; + }; + + maxUploadTimeout = mkOption { + type = types.str; + default = "30m"; + description = let + nginxCoreDocumentation = "http://nginx.org/en/docs/http/ngx_http_core_module.html"; + in + '' + Timeout for reading client request bodies and headers. Refer to + <link xlink:href="${nginxCoreDocumentation}#client_body_timeout"/> and + <link xlink:href="${nginxCoreDocumentation}#client_header_timeout"/> for accepted values. + ''; + }; + + nginxConfig = mkOption { + type = types.submodule + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }); + default = {}; + example = literalExpression '' + { + serverAliases = [ "wiki.''${config.networking.domain}" ]; + } + ''; + description = "Extra configuration for the nginx virtual host of Jirafeau."; + }; + + package = mkOption { + type = types.package; + default = pkgs.jirafeau; + defaultText = literalExpression "pkgs.jirafeau"; + description = "Jirafeau package to use"; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for Jirafeau PHP pool. See documentation on <literal>php-fpm.conf</literal> for + details on configuration directives. + ''; + }; + }; + + + config = mkIf cfg.enable { + services = { + nginx = { + enable = true; + virtualHosts."${cfg.hostName}" = mkMerge [ + cfg.nginxConfig + { + extraConfig = let + clientMaxBodySize = + if cfg.maxUploadSizeMegabytes == 0 then "0" else "${cfg.maxUploadSizeMegabytes}m"; + in + '' + index index.php; + client_max_body_size ${clientMaxBodySize}; + client_body_timeout ${cfg.maxUploadTimeout}; + client_header_timeout ${cfg.maxUploadTimeout}; + ''; + locations = { + "~ \\.php$".extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_index index.php; + fastcgi_pass unix:${config.services.phpfpm.pools.jirafeau.socket}; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + ''; + }; + root = mkForce "${cfg.package}"; + } + ]; + }; + + phpfpm.pools.jirafeau = { + inherit group user; + phpEnv."JIRAFEAU_CONFIG" = "${localConfig}"; + settings = { + "listen.mode" = "0660"; + "listen.owner" = user; + "listen.group" = group; + } // cfg.poolConfig; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/files/ 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/links/ 0750 ${user} ${group} - -" + "d ${cfg.dataDir}/async/ 0750 ${user} ${group} - -" + ]; + }; + + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixos/modules/services/web-apps/jitsi-meet.nix b/nixos/modules/services/web-apps/jitsi-meet.nix new file mode 100644 index 00000000000..2f1c4acec1e --- /dev/null +++ b/nixos/modules/services/web-apps/jitsi-meet.nix @@ -0,0 +1,452 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.jitsi-meet; + + # The configuration files are JS of format "var <<string>> = <<JSON>>;". In order to + # override only some settings, we need to extract the JSON, use jq to merge it with + # the config provided by user, and then reconstruct the file. + overrideJs = + source: varName: userCfg: appendExtra: + let + extractor = pkgs.writeText "extractor.js" '' + var fs = require("fs"); + eval(fs.readFileSync(process.argv[2], 'utf8')); + process.stdout.write(JSON.stringify(eval(process.argv[3]))); + ''; + userJson = pkgs.writeText "user.json" (builtins.toJSON userCfg); + in (pkgs.runCommand "${varName}.js" { } '' + ${pkgs.nodejs}/bin/node ${extractor} ${source} ${varName} > default.json + ( + echo "var ${varName} = " + ${pkgs.jq}/bin/jq -s '.[0] * .[1]' default.json ${userJson} + echo ";" + echo ${escapeShellArg appendExtra} + ) > $out + ''); + + # Essential config - it's probably not good to have these as option default because + # types.attrs doesn't do merging. Let's merge explicitly, can still be overriden if + # user desires. + defaultCfg = { + hosts = { + domain = cfg.hostName; + muc = "conference.${cfg.hostName}"; + focus = "focus.${cfg.hostName}"; + }; + bosh = "//${cfg.hostName}/http-bind"; + websocket = "wss://${cfg.hostName}/xmpp-websocket"; + + fileRecordingsEnabled = true; + liveStreamingEnabled = true; + hiddenDomain = "recorder.${cfg.hostName}"; + }; +in +{ + options.services.jitsi-meet = with types; { + enable = mkEnableOption "Jitsi Meet - Secure, Simple and Scalable Video Conferences"; + + hostName = mkOption { + type = str; + example = "meet.example.org"; + description = '' + FQDN of the Jitsi Meet instance. + ''; + }; + + config = mkOption { + type = attrs; + default = { }; + example = literalExpression '' + { + enableWelcomePage = false; + defaultLang = "fi"; + } + ''; + description = '' + Client-side web application settings that override the defaults in <filename>config.js</filename>. + + See <link xlink:href="https://github.com/jitsi/jitsi-meet/blob/master/config.js" /> for default + configuration with comments. + ''; + }; + + extraConfig = mkOption { + type = lines; + default = ""; + description = '' + Text to append to <filename>config.js</filename> web application config file. + + Can be used to insert JavaScript logic to determine user's region in cascading bridges setup. + ''; + }; + + interfaceConfig = mkOption { + type = attrs; + default = { }; + example = literalExpression '' + { + SHOW_JITSI_WATERMARK = false; + SHOW_WATERMARK_FOR_GUESTS = false; + } + ''; + description = '' + Client-side web-app interface settings that override the defaults in <filename>interface_config.js</filename>. + + See <link xlink:href="https://github.com/jitsi/jitsi-meet/blob/master/interface_config.js" /> for + default configuration with comments. + ''; + }; + + videobridge = { + enable = mkOption { + type = bool; + default = true; + description = '' + Whether to enable Jitsi Videobridge instance and configure it to connect to Prosody. + + Additional configuration is possible with <option>services.jitsi-videobridge</option>. + ''; + }; + + passwordFile = mkOption { + type = nullOr str; + default = null; + example = "/run/keys/videobridge"; + description = '' + File containing password to the Prosody account for videobridge. + + If <literal>null</literal>, a file with password will be generated automatically. Setting + this option is useful if you plan to connect additional videobridges to the XMPP server. + ''; + }; + }; + + jicofo.enable = mkOption { + type = bool; + default = true; + description = '' + Whether to enable JiCoFo instance and configure it to connect to Prosody. + + Additional configuration is possible with <option>services.jicofo</option>. + ''; + }; + + jibri.enable = mkOption { + type = bool; + default = false; + description = '' + Whether to enable a Jibri instance and configure it to connect to Prosody. + + Additional configuration is possible with <option>services.jibri</option>, and + <option>services.jibri.finalizeScript</option> is especially useful. + ''; + }; + + nginx.enable = mkOption { + type = bool; + default = true; + description = '' + Whether to enable nginx virtual host that will serve the javascript application and act as + a proxy for the XMPP server. Further nginx configuration can be done by adapting + <option>services.nginx.virtualHosts.<hostName></option>. + When this is enabled, ACME will be used to retrieve a TLS certificate by default. To disable + this, set the <option>services.nginx.virtualHosts.<hostName>.enableACME</option> to + <literal>false</literal> and if appropriate do the same for + <option>services.nginx.virtualHosts.<hostName>.forceSSL</option>. + ''; + }; + + caddy.enable = mkEnableOption "Whether to enablle caddy reverse proxy to expose jitsi-meet"; + + prosody.enable = mkOption { + type = bool; + default = true; + description = '' + Whether to configure Prosody to relay XMPP messages between Jitsi Meet components. Turn this + off if you want to configure it manually. + ''; + }; + }; + + config = mkIf cfg.enable { + services.prosody = mkIf cfg.prosody.enable { + enable = mkDefault true; + xmppComplianceSuite = mkDefault false; + modules = { + admin_adhoc = mkDefault false; + bosh = mkDefault true; + ping = mkDefault true; + roster = mkDefault true; + saslauth = mkDefault true; + smacks = mkDefault true; + tls = mkDefault true; + websocket = mkDefault true; + }; + muc = [ + { + domain = "conference.${cfg.hostName}"; + name = "Jitsi Meet MUC"; + roomLocking = false; + roomDefaultPublicJids = true; + extraConfig = '' + storage = "memory" + ''; + } + { + domain = "internal.${cfg.hostName}"; + name = "Jitsi Meet Videobridge MUC"; + extraConfig = '' + storage = "memory" + admins = { "focus@auth.${cfg.hostName}", "jvb@auth.${cfg.hostName}" } + ''; + #-- muc_room_cache_size = 1000 + } + ]; + extraModules = [ "pubsub" "smacks" ]; + extraPluginPaths = [ "${pkgs.jitsi-meet-prosody}/share/prosody-plugins" ]; + extraConfig = lib.mkMerge [ (mkAfter '' + Component "focus.${cfg.hostName}" "client_proxy" + target_address = "focus@auth.${cfg.hostName}" + '') + (mkBefore '' + cross_domain_websocket = true; + consider_websocket_secure = true; + '') + ]; + virtualHosts.${cfg.hostName} = { + enabled = true; + domain = cfg.hostName; + extraConfig = '' + authentication = "anonymous" + c2s_require_encryption = false + admins = { "focus@auth.${cfg.hostName}" } + smacks_max_unacked_stanzas = 5 + smacks_hibernation_time = 60 + smacks_max_hibernated_sessions = 1 + smacks_max_old_sessions = 1 + ''; + ssl = { + cert = "/var/lib/jitsi-meet/jitsi-meet.crt"; + key = "/var/lib/jitsi-meet/jitsi-meet.key"; + }; + }; + virtualHosts."auth.${cfg.hostName}" = { + enabled = true; + domain = "auth.${cfg.hostName}"; + extraConfig = '' + authentication = "internal_plain" + ''; + ssl = { + cert = "/var/lib/jitsi-meet/jitsi-meet.crt"; + key = "/var/lib/jitsi-meet/jitsi-meet.key"; + }; + }; + virtualHosts."recorder.${cfg.hostName}" = { + enabled = true; + domain = "recorder.${cfg.hostName}"; + extraConfig = '' + authentication = "internal_plain" + c2s_require_encryption = false + ''; + }; + }; + systemd.services.prosody.serviceConfig = mkIf cfg.prosody.enable { + EnvironmentFile = [ "/var/lib/jitsi-meet/secrets-env" ]; + SupplementaryGroups = [ "jitsi-meet" ]; + }; + + users.groups.jitsi-meet = {}; + systemd.tmpfiles.rules = [ + "d '/var/lib/jitsi-meet' 0750 root jitsi-meet - -" + ]; + + systemd.services.jitsi-meet-init-secrets = { + wantedBy = [ "multi-user.target" ]; + before = [ "jicofo.service" "jitsi-videobridge2.service" ] ++ (optional cfg.prosody.enable "prosody.service"); + path = [ config.services.prosody.package ]; + serviceConfig = { + Type = "oneshot"; + }; + + script = let + secrets = [ "jicofo-component-secret" "jicofo-user-secret" "jibri-auth-secret" "jibri-recorder-secret" ] ++ (optional (cfg.videobridge.passwordFile == null) "videobridge-secret"); + videobridgeSecret = if cfg.videobridge.passwordFile != null then cfg.videobridge.passwordFile else "/var/lib/jitsi-meet/videobridge-secret"; + in + '' + cd /var/lib/jitsi-meet + ${concatMapStringsSep "\n" (s: '' + if [ ! -f ${s} ]; then + tr -dc a-zA-Z0-9 </dev/urandom | head -c 64 > ${s} + chown root:jitsi-meet ${s} + chmod 640 ${s} + fi + '') secrets} + + # for easy access in prosody + echo "JICOFO_COMPONENT_SECRET=$(cat jicofo-component-secret)" > secrets-env + chown root:jitsi-meet secrets-env + chmod 640 secrets-env + '' + + optionalString cfg.prosody.enable '' + prosodyctl register focus auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jicofo-user-secret)" + prosodyctl register jvb auth.${cfg.hostName} "$(cat ${videobridgeSecret})" + prosodyctl mod_roster_command subscribe focus.${cfg.hostName} focus@auth.${cfg.hostName} + prosodyctl register jibri auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-auth-secret)" + prosodyctl register recorder recorder.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-recorder-secret)" + + # generate self-signed certificates + if [ ! -f /var/lib/jitsi-meet.crt ]; then + ${getBin pkgs.openssl}/bin/openssl req \ + -x509 \ + -newkey rsa:4096 \ + -keyout /var/lib/jitsi-meet/jitsi-meet.key \ + -out /var/lib/jitsi-meet/jitsi-meet.crt \ + -days 36500 \ + -nodes \ + -subj '/CN=${cfg.hostName}/CN=auth.${cfg.hostName}' + chmod 640 /var/lib/jitsi-meet/jitsi-meet.{crt,key} + chown root:jitsi-meet /var/lib/jitsi-meet/jitsi-meet.{crt,key} + fi + ''; + }; + + services.nginx = mkIf cfg.nginx.enable { + enable = mkDefault true; + virtualHosts.${cfg.hostName} = { + enableACME = mkDefault true; + forceSSL = mkDefault true; + root = pkgs.jitsi-meet; + extraConfig = '' + ssi on; + ''; + locations."@root_path".extraConfig = '' + rewrite ^/(.*)$ / break; + ''; + locations."~ ^/([^/\\?&:'\"]+)$".tryFiles = "$uri @root_path"; + locations."^~ /xmpp-websocket" = { + priority = 100; + proxyPass = "http://localhost:5280/xmpp-websocket"; + proxyWebsockets = true; + }; + locations."=/http-bind" = { + proxyPass = "http://localhost:5280/http-bind"; + extraConfig = '' + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + ''; + }; + locations."=/external_api.js" = mkDefault { + alias = "${pkgs.jitsi-meet}/libs/external_api.min.js"; + }; + locations."=/config.js" = mkDefault { + alias = overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config) cfg.extraConfig; + }; + locations."=/interface_config.js" = mkDefault { + alias = overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig ""; + }; + }; + }; + + services.caddy = mkIf cfg.caddy.enable { + enable = mkDefault true; + virtualHosts.${cfg.hostName} = { + extraConfig = + let + templatedJitsiMeet = pkgs.runCommand "templated-jitsi-meet" {} '' + cp -R ${pkgs.jitsi-meet}/* . + for file in *.html **/*.html ; do + ${pkgs.sd}/bin/sd '<!--#include virtual="(.*)" -->' '{{ include "$1" }}' $file + done + rm config.js + rm interface_config.js + cp -R . $out + cp ${overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config) cfg.extraConfig} $out/config.js + cp ${overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig ""} $out/interface_config.js + cp ./libs/external_api.min.js $out/external_api.js + ''; + in '' + handle /http-bind { + header Host ${cfg.hostName} + reverse_proxy 127.0.0.1:5280 + } + handle /xmpp-websocket { + reverse_proxy 127.0.0.1:5280 + } + handle { + templates + root * ${templatedJitsiMeet} + try_files {path} {path} + try_files {path} /index.html + file_server + } + ''; + }; + }; + + services.jitsi-videobridge = mkIf cfg.videobridge.enable { + enable = true; + xmppConfigs."localhost" = { + userName = "jvb"; + domain = "auth.${cfg.hostName}"; + passwordFile = "/var/lib/jitsi-meet/videobridge-secret"; + mucJids = "jvbbrewery@internal.${cfg.hostName}"; + disableCertificateVerification = true; + }; + }; + + services.jicofo = mkIf cfg.jicofo.enable { + enable = true; + xmppHost = "localhost"; + xmppDomain = cfg.hostName; + userDomain = "auth.${cfg.hostName}"; + userName = "focus"; + userPasswordFile = "/var/lib/jitsi-meet/jicofo-user-secret"; + componentPasswordFile = "/var/lib/jitsi-meet/jicofo-component-secret"; + bridgeMuc = "jvbbrewery@internal.${cfg.hostName}"; + config = mkMerge [{ + "org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED" = "true"; + #} (lib.mkIf cfg.jibri.enable { + } (lib.mkIf (config.services.jibri.enable || cfg.jibri.enable) { + "org.jitsi.jicofo.jibri.BREWERY" = "JibriBrewery@internal.${cfg.hostName}"; + "org.jitsi.jicofo.jibri.PENDING_TIMEOUT" = "90"; + })]; + }; + + services.jibri = mkIf cfg.jibri.enable { + enable = true; + + xmppEnvironments."jitsi-meet" = { + xmppServerHosts = [ "localhost" ]; + xmppDomain = cfg.hostName; + + control.muc = { + domain = "internal.${cfg.hostName}"; + roomName = "JibriBrewery"; + nickname = "jibri"; + }; + + control.login = { + domain = "auth.${cfg.hostName}"; + username = "jibri"; + passwordFile = "/var/lib/jitsi-meet/jibri-auth-secret"; + }; + + call.login = { + domain = "recorder.${cfg.hostName}"; + username = "recorder"; + passwordFile = "/var/lib/jitsi-meet/jibri-recorder-secret"; + }; + + usageTimeout = "0"; + disableCertificateVerification = true; + stripFromRoomDomain = "conference."; + }; + }; + }; + + meta.doc = ./jitsi-meet.xml; + meta.maintainers = lib.teams.jitsi.members; +} diff --git a/nixos/modules/services/web-apps/jitsi-meet.xml b/nixos/modules/services/web-apps/jitsi-meet.xml new file mode 100644 index 00000000000..ff44c724adf --- /dev/null +++ b/nixos/modules/services/web-apps/jitsi-meet.xml @@ -0,0 +1,55 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-jitsi-meet"> + <title>Jitsi Meet</title> + <para> + With Jitsi Meet on NixOS you can quickly configure a complete, + private, self-hosted video conferencing solution. + </para> + + <section xml:id="module-services-jitsi-basic-usage"> + <title>Basic usage</title> + <para> + A minimal configuration using Let's Encrypt for TLS certificates looks like this: +<programlisting>{ + services.jitsi-meet = { + <link linkend="opt-services.jitsi-meet.enable">enable</link> = true; + <link linkend="opt-services.jitsi-meet.enable">hostName</link> = "jitsi.example.com"; + }; + <link linkend="opt-services.jitsi-videobridge.openFirewall">services.jitsi-videobridge.openFirewall</link> = true; + <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ]; + <link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com"; + <link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true; +}</programlisting> + </para> + </section> + + <section xml:id="module-services-jitsi-configuration"> + <title>Configuration</title> + <para> + Here is the minimal configuration with additional configurations: +<programlisting>{ + services.jitsi-meet = { + <link linkend="opt-services.jitsi-meet.enable">enable</link> = true; + <link linkend="opt-services.jitsi-meet.enable">hostName</link> = "jitsi.example.com"; + <link linkend="opt-services.jitsi-meet.config">config</link> = { + enableWelcomePage = false; + prejoinPageEnabled = true; + defaultLang = "fi"; + }; + <link linkend="opt-services.jitsi-meet.interfaceConfig">interfaceConfig</link> = { + SHOW_JITSI_WATERMARK = false; + SHOW_WATERMARK_FOR_GUESTS = false; + }; + }; + <link linkend="opt-services.jitsi-videobridge.openFirewall">services.jitsi-videobridge.openFirewall</link> = true; + <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ]; + <link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com"; + <link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true; +}</programlisting> + </para> + </section> + +</chapter> diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix new file mode 100644 index 00000000000..22c16be7613 --- /dev/null +++ b/nixos/modules/services/web-apps/keycloak.nix @@ -0,0 +1,819 @@ +{ config, options, pkgs, lib, ... }: + +let + cfg = config.services.keycloak; + opt = options.services.keycloak; + + inherit (lib) types mkOption concatStringsSep mapAttrsToList + escapeShellArg recursiveUpdate optionalAttrs boolToString mkOrder + sort filterAttrs concatMapStringsSep concatStrings mkIf + optionalString optionals mkDefault literalExpression hasSuffix + foldl' isAttrs filter attrNames elem literalDocBook + maintainers; + + inherit (builtins) match typeOf; +in +{ + options.services.keycloak = + let + inherit (types) bool str nullOr attrsOf path enum anything + package port; + in + { + enable = mkOption { + type = bool; + default = false; + example = true; + description = '' + Whether to enable the Keycloak identity and access management + server. + ''; + }; + + bindAddress = mkOption { + type = str; + default = "\${jboss.bind.address:0.0.0.0}"; + example = "127.0.0.1"; + description = '' + On which address Keycloak should accept new connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + httpPort = mkOption { + type = str; + default = "\${jboss.http.port:80}"; + example = "8080"; + description = '' + On which port Keycloak should listen for new HTTP connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + httpsPort = mkOption { + type = str; + default = "\${jboss.https.port:443}"; + example = "8443"; + description = '' + On which port Keycloak should listen for new HTTPS connections. + + A special syntax can be used to allow command line Java system + properties to override the value: ''${property.name:value} + ''; + }; + + frontendUrl = mkOption { + type = str; + apply = x: + if x == "" || hasSuffix "/" x then + x + else + x + "/"; + example = "keycloak.example.com/auth"; + description = '' + The public URL used as base for all frontend requests. Should + normally include a trailing <literal>/auth</literal>. + + See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the + Hostname section of the Keycloak server installation + manual</link> for more information. + ''; + }; + + forceBackendUrlToFrontendUrl = mkOption { + type = bool; + default = false; + example = true; + description = '' + Whether Keycloak should force all requests to go through the + frontend URL configured in <xref + linkend="opt-services.keycloak.frontendUrl" />. By default, + Keycloak allows backend requests to instead use its local + hostname or IP address and may also advertise it to clients + through its OpenID Connect Discovery endpoint. + + See <link + xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the + Hostname section of the Keycloak server installation + manual</link> for more information. + ''; + }; + + sslCertificate = mkOption { + type = nullOr path; + default = null; + example = "/run/keys/ssl_cert"; + description = '' + The path to a PEM formatted certificate to use for TLS/SSL + connections. + + This should be a string, not a Nix path, since Nix paths are + copied into the world-readable Nix store. + ''; + }; + + sslCertificateKey = mkOption { + type = nullOr path; + default = null; + example = "/run/keys/ssl_key"; + description = '' + The path to a PEM formatted private key to use for TLS/SSL + connections. + + This should be a string, not a Nix path, since Nix paths are + copied into the world-readable Nix store. + ''; + }; + + database = { + type = mkOption { + type = enum [ "mysql" "postgresql" ]; + default = "postgresql"; + example = "mysql"; + description = '' + The type of database Keycloak should connect to. + ''; + }; + + host = mkOption { + type = str; + default = "localhost"; + description = '' + Hostname of the database to connect to. + ''; + }; + + port = + let + dbPorts = { + postgresql = 5432; + mysql = 3306; + }; + in + mkOption { + type = port; + default = dbPorts.${cfg.database.type}; + defaultText = literalDocBook "default port of selected database"; + description = '' + Port of the database to connect to. + ''; + }; + + useSSL = mkOption { + type = bool; + default = cfg.database.host != "localhost"; + defaultText = literalExpression ''config.${opt.database.host} != "localhost"''; + description = '' + Whether the database connection should be secured by SSL / + TLS. + ''; + }; + + caCert = mkOption { + type = nullOr path; + default = null; + description = '' + The SSL / TLS CA certificate that verifies the identity of the + database server. + + Required when PostgreSQL is used and SSL is turned on. + + For MySQL, if left at <literal>null</literal>, the default + Java keystore is used, which should suffice if the server + certificate is issued by an official CA. + ''; + }; + + createLocally = mkOption { + type = bool; + default = true; + description = '' + Whether a database should be automatically created on the + local host. Set this to false if you plan on provisioning a + local database yourself. This has no effect if + services.keycloak.database.host is customized. + ''; + }; + + username = mkOption { + type = str; + default = "keycloak"; + description = '' + Username to use when connecting to an external or manually + provisioned database; has no effect when a local database is + automatically provisioned. + + To use this with a local database, set <xref + linkend="opt-services.keycloak.database.createLocally" /> to + <literal>false</literal> and create the database and user + manually. The database should be called + <literal>keycloak</literal>. + ''; + }; + + passwordFile = mkOption { + type = path; + example = "/run/keys/db_password"; + description = '' + File containing the database password. + + This should be a string, not a Nix path, since Nix paths are + copied into the world-readable Nix store. + ''; + }; + }; + + package = mkOption { + type = package; + default = pkgs.keycloak; + defaultText = literalExpression "pkgs.keycloak"; + description = '' + Keycloak package to use. + ''; + }; + + initialAdminPassword = mkOption { + type = str; + default = "changeme"; + description = '' + Initial password set for the <literal>admin</literal> + user. The password is not stored safely and should be changed + immediately in the admin panel. + ''; + }; + + themes = mkOption { + type = attrsOf package; + default = { }; + description = '' + Additional theme packages for Keycloak. Each theme is linked into + subdirectory with a corresponding attribute name. + + Theme packages consist of several subdirectories which provide + different theme types: for example, <literal>account</literal>, + <literal>login</literal> etc. After adding a theme to this option you + can select it by its name in Keycloak administration console. + ''; + }; + + extraConfig = mkOption { + type = attrsOf anything; + default = { }; + example = literalExpression '' + { + "subsystem=keycloak-server" = { + "spi=hostname" = { + "provider=default" = null; + "provider=fixed" = { + enabled = true; + properties.hostname = "keycloak.example.com"; + }; + default-provider = "fixed"; + }; + }; + } + ''; + description = '' + Additional Keycloak configuration options to set in + <literal>standalone.xml</literal>. + + Options are expressed as a Nix attribute set which matches the + structure of the jboss-cli configuration. The configuration is + effectively overlayed on top of the default configuration + shipped with Keycloak. To remove existing nodes and undefine + attributes from the default configuration, set them to + <literal>null</literal>. + + The example configuration does the equivalent of the following + script, which removes the hostname provider + <literal>default</literal>, adds the deprecated hostname + provider <literal>fixed</literal> and defines it the default: + + <programlisting> + /subsystem=keycloak-server/spi=hostname/provider=default:remove() + /subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" }) + /subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed") + </programlisting> + + You can discover available options by using the <link + xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link> + program and by referring to the <link + xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak + Server Installation and Configuration Guide</link>. + ''; + }; + + }; + + config = + let + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost"; + createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql"; + createLocalMySQL = databaseActuallyCreateLocally && cfg.database.type == "mysql"; + + mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } '' + ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt + ''; + + # Both theme and theme type directories need to be actual directories in one hierarchy to pass Keycloak checks. + themesBundle = pkgs.runCommand "keycloak-themes" { } '' + linkTheme() { + theme="$1" + name="$2" + + mkdir "$out/$name" + for typeDir in "$theme"/*; do + if [ -d "$typeDir" ]; then + type="$(basename "$typeDir")" + mkdir "$out/$name/$type" + for file in "$typeDir"/*; do + ln -sn "$file" "$out/$name/$type/$(basename "$file")" + done + fi + done + } + + mkdir -p "$out" + for theme in ${cfg.package}/themes/*; do + if [ -d "$theme" ]; then + linkTheme "$theme" "$(basename "$theme")" + fi + done + + ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)} + ''; + + keycloakConfig' = foldl' recursiveUpdate + { + "interface=public".inet-address = cfg.bindAddress; + "socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort; + "subsystem=keycloak-server" = { + "spi=hostname"."provider=default" = { + enabled = true; + properties = { + inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl; + }; + }; + "theme=defaults".dir = toString themesBundle; + }; + "subsystem=datasources"."data-source=KeycloakDS" = { + max-pool-size = "20"; + user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username; + password = "@db-password@"; + }; + } [ + (optionalAttrs (cfg.database.type == "postgresql") { + "subsystem=datasources" = { + "jdbc-driver=postgresql" = { + driver-module-name = "org.postgresql"; + driver-name = "postgresql"; + driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource"; + }; + "data-source=KeycloakDS" = { + connection-url = "jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/keycloak"; + driver-name = "postgresql"; + "connection-properties=ssl".value = boolToString cfg.database.useSSL; + } // (optionalAttrs (cfg.database.caCert != null) { + "connection-properties=sslrootcert".value = cfg.database.caCert; + "connection-properties=sslmode".value = "verify-ca"; + }); + }; + }) + (optionalAttrs (cfg.database.type == "mysql") { + "subsystem=datasources" = { + "jdbc-driver=mysql" = { + driver-module-name = "com.mysql"; + driver-name = "mysql"; + driver-class-name = "com.mysql.jdbc.Driver"; + }; + "data-source=KeycloakDS" = { + connection-url = "jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/keycloak"; + driver-name = "mysql"; + "connection-properties=useSSL".value = boolToString cfg.database.useSSL; + "connection-properties=requireSSL".value = boolToString cfg.database.useSSL; + "connection-properties=verifyServerCertificate".value = boolToString cfg.database.useSSL; + "connection-properties=characterEncoding".value = "UTF-8"; + valid-connection-checker-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker"; + validate-on-match = true; + exception-sorter-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter"; + } // (optionalAttrs (cfg.database.caCert != null) { + "connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}"; + "connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword"; + }); + }; + }) + (optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) { + "socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort; + "subsystem=elytron" = mkOrder 900 { + "key-store=httpsKS" = mkOrder 900 { + path = "/run/keycloak/ssl/certificate_private_key_bundle.p12"; + credential-reference.clear-text = "notsosecretpassword"; + type = "JKS"; + }; + "key-manager=httpsKM" = mkOrder 901 { + key-store = "httpsKS"; + credential-reference.clear-text = "notsosecretpassword"; + }; + "server-ssl-context=httpsSSC" = mkOrder 902 { + key-manager = "httpsKM"; + }; + }; + "subsystem=undertow" = mkOrder 901 { + "server=default-server"."https-listener=https".ssl-context = "httpsSSC"; + }; + }) + cfg.extraConfig + ]; + + + /* Produces a JBoss CLI script that creates paths and sets + attributes matching those described by `attrs`. When the + script is run, the existing settings are effectively overlayed + by those from `attrs`. Existing attributes can be unset by + defining them `null`. + + JBoss paths and attributes / maps are distinguished by their + name, where paths follow a `key=value` scheme. + + Example: + mkJbossScript { + "subsystem=keycloak-server"."spi=hostname" = { + "provider=fixed" = null; + "provider=default" = { + enabled = true; + properties = { + inherit frontendUrl; + forceBackendUrlToFrontendUrl = false; + }; + }; + }; + } + => '' + if (outcome != success) of /:read-resource() + /:add() + end-if + if (outcome != success) of /subsystem=keycloak-server:read-resource() + /subsystem=keycloak-server:add() + end-if + if (outcome != success) of /subsystem=keycloak-server/spi=hostname:read-resource() + /subsystem=keycloak-server/spi=hostname:add() + end-if + if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=default:read-resource() + /subsystem=keycloak-server/spi=hostname/provider=default:add(enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" }) + end-if + if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true) + end-if + if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false) + end-if + if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth") + end-if + if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=fixed:read-resource() + /subsystem=keycloak-server/spi=hostname/provider=fixed:remove() + end-if + '' + */ + mkJbossScript = attrs: + let + /* From a JBoss path and an attrset, produces a JBoss CLI + snippet that writes the corresponding attributes starting + at `path`. Recurses down into subattrsets as necessary, + producing the variable name from its full path in the + attrset. + + Example: + writeAttributes "/subsystem=keycloak-server/spi=hostname/provider=default" { + enabled = true; + properties = { + forceBackendUrlToFrontendUrl = false; + frontendUrl = "https://keycloak.example.com/auth"; + }; + } + => '' + if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true) + end-if + if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false) + end-if + if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl") + /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth") + end-if + '' + */ + writeAttributes = path: set: + let + # JBoss expressions like `${var}` need to be prefixed + # with `expression` to evaluate. + prefixExpression = string: + let + matchResult = match ''"\$\{.*}"'' string; + in + if matchResult != null then + "expression " + string + else + string; + + writeAttribute = attribute: value: + let + type = typeOf value; + in + if type == "set" then + let + names = attrNames value; + in + foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names + else if value == null then '' + if (outcome == success) of ${path}:read-attribute(name="${attribute}") + ${path}:undefine-attribute(name="${attribute}") + end-if + '' + else if elem type [ "string" "path" "bool" ] then + let + value' = if type == "bool" then boolToString value else ''"${value}"''; + in + '' + if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}") + ${path}:write-attribute(name=${attribute}, value=${value'}) + end-if + '' + else throw "Unsupported type '${type}' for path '${path}'!"; + in + concatStrings + (mapAttrsToList + (attribute: value: (writeAttribute attribute value)) + set); + + + /* Produces an argument list for the JBoss `add()` function, + which adds a JBoss path and takes as its arguments the + required subpaths and attributes. + + Example: + makeArgList { + enabled = true; + properties = { + forceBackendUrlToFrontendUrl = false; + frontendUrl = "https://keycloak.example.com/auth"; + }; + } + => '' + enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" } + '' + */ + makeArgList = set: + let + makeArg = attribute: value: + let + type = typeOf value; + in + if type == "set" then + "${attribute} = { " + (makeArgList value) + " }" + else if elem type [ "string" "path" "bool" ] then + "${attribute} = ${if type == "bool" then boolToString value else ''"${value}"''}" + else if value == null then + "" + else + throw "Unsupported type '${type}' for attribute '${attribute}'!"; + + in + concatStringsSep ", " (mapAttrsToList makeArg set); + + + /* Recurses into the `nodeValue` attrset. Only subattrsets that + are JBoss paths, i.e. follows the `key=value` format, are recursed + into - the rest are considered JBoss attributes / maps. + */ + recurse = nodePath: nodeValue: + let + nodeContent = + if isAttrs nodeValue && nodeValue._type or "" == "order" then + nodeValue.content + else + nodeValue; + isPath = name: + let + value = nodeContent.${name}; + in + if (match ".*([=]).*" name) == [ "=" ] then + if isAttrs value || value == null then + true + else + throw "Parsing path '${concatStringsSep "." (nodePath ++ [ name ])}' failed: JBoss attributes cannot contain '='!" + else + false; + jbossPath = "/" + concatStringsSep "/" nodePath; + children = if !isAttrs nodeContent then { } else nodeContent; + subPaths = filter isPath (attrNames children); + getPriority = name: + let + value = children.${name}; + in + if value._type or "" == "order" then value.priority else 1000; + orderedSubPaths = sort (a: b: getPriority a < getPriority b) subPaths; + jbossAttrs = filterAttrs (name: _: !(isPath name)) children; + text = + if nodeContent != null then + '' + if (outcome != success) of ${jbossPath}:read-resource() + ${jbossPath}:add(${makeArgList jbossAttrs}) + end-if + '' + writeAttributes jbossPath jbossAttrs + else + '' + if (outcome == success) of ${jbossPath}:read-resource() + ${jbossPath}:remove() + end-if + ''; + in + text + concatMapStringsSep "\n" (name: recurse (nodePath ++ [ name ]) children.${name}) orderedSubPaths; + in + recurse [ ] attrs; + + jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig'); + + keycloakConfig = pkgs.runCommand "keycloak-config" + { + nativeBuildInputs = [ cfg.package ]; + } + '' + export JBOSS_BASE_DIR="$(pwd -P)"; + export JBOSS_MODULEPATH="${cfg.package}/modules"; + export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log"; + + cp -r ${cfg.package}/standalone/configuration . + chmod -R u+rwX ./configuration + + mkdir -p {deployments,ssl} + + standalone.sh& + + attempt=1 + max_attempts=30 + while ! jboss-cli.sh --connect ':read-attribute(name=server-state)'; do + if [[ "$attempt" == "$max_attempts" ]]; then + echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2 + exit 1 + fi + echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)" + sleep 1 + (( attempt++ )) + done + + jboss-cli.sh --connect --file=${jbossCliScript} --echo-command + + cp configuration/standalone.xml $out + ''; + in + mkIf cfg.enable + { + assertions = [ + { + assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null); + message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL"; + } + ]; + + environment.systemPackages = [ cfg.package ]; + + systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL { + after = [ "postgresql.service" ]; + before = [ "keycloak.service" ]; + bindsTo = [ "postgresql.service" ]; + path = [ config.services.postgresql.package ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "postgres"; + Group = "postgres"; + LoadCredential = [ "db_password:${cfg.database.passwordFile}" ]; + }; + script = '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + create_role="$(mktemp)" + trap 'rm -f "$create_role"' ERR EXIT + + db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")" + echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role" + psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role" + psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"' + ''; + }; + + systemd.services.keycloakMySQLInit = mkIf createLocalMySQL { + after = [ "mysql.service" ]; + before = [ "keycloak.service" ]; + bindsTo = [ "mysql.service" ]; + path = [ config.services.mysql.package ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = config.services.mysql.user; + Group = config.services.mysql.group; + LoadCredential = [ "db_password:${cfg.database.passwordFile}" ]; + }; + script = '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")" + ( echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';" + echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;" + echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';" + ) | mysql -N + ''; + }; + + systemd.services.keycloak = + let + databaseServices = + if createLocalPostgreSQL then [ + "keycloakPostgreSQLInit.service" + "postgresql.service" + ] + else if createLocalMySQL then [ + "keycloakMySQLInit.service" + "mysql.service" + ] + else [ ]; + in + { + after = databaseServices; + bindsTo = databaseServices; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ + cfg.package + openssl + replace-secret + ]; + environment = { + JBOSS_LOG_DIR = "/var/log/keycloak"; + JBOSS_BASE_DIR = "/run/keycloak"; + JBOSS_MODULEPATH = "${cfg.package}/modules"; + }; + serviceConfig = { + LoadCredential = [ + "db_password:${cfg.database.passwordFile}" + ] ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [ + "ssl_cert:${cfg.sslCertificate}" + "ssl_key:${cfg.sslCertificateKey}" + ]; + User = "keycloak"; + Group = "keycloak"; + DynamicUser = true; + RuntimeDirectory = map (p: "keycloak/" + p) [ + "configuration" + "deployments" + "data" + "ssl" + "log" + "tmp" + ]; + RuntimeDirectoryMode = 0700; + LogsDirectory = "keycloak"; + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + }; + script = '' + set -o errexit -o pipefail -o nounset -o errtrace + shopt -s inherit_errexit + + umask u=rwx,g=,o= + + install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration + install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml + + replace-secret '@db-password@' "$CREDENTIALS_DIRECTORY/db_password" /run/keycloak/configuration/standalone.xml + + export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration + add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}' + '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) '' + pushd /run/keycloak/ssl/ + cat "$CREDENTIALS_DIRECTORY/ssl_cert" <(echo) \ + "$CREDENTIALS_DIRECTORY/ssl_key" <(echo) \ + /etc/ssl/certs/ca-certificates.crt \ + > allcerts.pem + openssl pkcs12 -export -in "$CREDENTIALS_DIRECTORY/ssl_cert" -inkey "$CREDENTIALS_DIRECTORY/ssl_key" -chain \ + -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \ + -CAfile allcerts.pem -passout pass:notsosecretpassword + popd + '' + '' + ${cfg.package}/bin/standalone.sh + ''; + }; + + services.postgresql.enable = mkDefault createLocalPostgreSQL; + services.mysql.enable = mkDefault createLocalMySQL; + services.mysql.package = mkIf createLocalMySQL pkgs.mariadb; + }; + + meta.doc = ./keycloak.xml; + meta.maintainers = [ maintainers.talyz ]; +} diff --git a/nixos/modules/services/web-apps/keycloak.xml b/nixos/modules/services/web-apps/keycloak.xml new file mode 100644 index 00000000000..cb706932f48 --- /dev/null +++ b/nixos/modules/services/web-apps/keycloak.xml @@ -0,0 +1,222 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-keycloak"> + <title>Keycloak</title> + <para> + <link xlink:href="https://www.keycloak.org/">Keycloak</link> is an + open source identity and access management server with support for + <link xlink:href="https://openid.net/connect/">OpenID + Connect</link>, <link xlink:href="https://oauth.net/2/">OAUTH + 2.0</link> and <link + xlink:href="https://en.wikipedia.org/wiki/SAML_2.0">SAML + 2.0</link>. + </para> + <section xml:id="module-services-keycloak-admin"> + <title>Administration</title> + <para> + An administrative user with the username + <literal>admin</literal> is automatically created in the + <literal>master</literal> realm. Its initial password can be + configured by setting <xref linkend="opt-services.keycloak.initialAdminPassword" /> + and defaults to <literal>changeme</literal>. The password is + not stored safely and should be changed immediately in the + admin panel. + </para> + + <para> + Refer to the <link + xlink:href="https://www.keycloak.org/docs/latest/server_admin/index.html#admin-console">Admin + Console section of the Keycloak Server Administration Guide</link> for + information on how to administer your + <productname>Keycloak</productname> instance. + </para> + </section> + + <section xml:id="module-services-keycloak-database"> + <title>Database access</title> + <para> + <productname>Keycloak</productname> can be used with either + <productname>PostgreSQL</productname> or + <productname>MySQL</productname>. Which one is used can be + configured in <xref + linkend="opt-services.keycloak.database.type" />. The selected + database will automatically be enabled and a database and role + created unless <xref + linkend="opt-services.keycloak.database.host" /> is changed from + its default of <literal>localhost</literal> or <xref + linkend="opt-services.keycloak.database.createLocally" /> is set + to <literal>false</literal>. + </para> + + <para> + External database access can also be configured by setting + <xref linkend="opt-services.keycloak.database.host" />, <xref + linkend="opt-services.keycloak.database.username" />, <xref + linkend="opt-services.keycloak.database.useSSL" /> and <xref + linkend="opt-services.keycloak.database.caCert" /> as + appropriate. Note that you need to manually create a database + called <literal>keycloak</literal> and allow the configured + database user full access to it. + </para> + + <para> + <xref linkend="opt-services.keycloak.database.passwordFile" /> + must be set to the path to a file containing the password used + to log in to the database. If <xref linkend="opt-services.keycloak.database.host" /> + and <xref linkend="opt-services.keycloak.database.createLocally" /> + are kept at their defaults, the database role + <literal>keycloak</literal> with that password is provisioned + on the local database instance. + </para> + + <warning> + <para> + The path should be provided as a string, not a Nix path, since Nix + paths are copied into the world readable Nix store. + </para> + </warning> + </section> + + <section xml:id="module-services-keycloak-frontendurl"> + <title>Frontend URL</title> + <para> + The frontend URL is used as base for all frontend requests and + must be configured through <xref linkend="opt-services.keycloak.frontendUrl" />. + It should normally include a trailing <literal>/auth</literal> + (the default web context). If you use a reverse proxy, you need + to set this option to <literal>""</literal>, so that frontend URL + is derived from HTTP headers. <literal>X-Forwarded-*</literal> headers + support also should be enabled, using <link + xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html#identifying-client-ip-addresses"> + respective guidelines</link>. + </para> + + <para> + <xref linkend="opt-services.keycloak.forceBackendUrlToFrontendUrl" /> + determines whether Keycloak should force all requests to go + through the frontend URL. By default, + <productname>Keycloak</productname> allows backend requests to + instead use its local hostname or IP address and may also + advertise it to clients through its OpenID Connect Discovery + endpoint. + </para> + + <para> + See the <link + xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">Hostname + section of the Keycloak Server Installation and Configuration + Guide</link> for more information. + </para> + </section> + + <section xml:id="module-services-keycloak-tls"> + <title>Setting up TLS/SSL</title> + <para> + By default, <productname>Keycloak</productname> won't accept + unsecured HTTP connections originating from outside its local + network. + </para> + + <para> + HTTPS support requires a TLS/SSL certificate and a private key, + both <link + xlink:href="https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail">PEM + formatted</link>. Their paths should be set through <xref + linkend="opt-services.keycloak.sslCertificate" /> and <xref + linkend="opt-services.keycloak.sslCertificateKey" />. + </para> + + <warning> + <para> + The paths should be provided as a strings, not a Nix paths, + since Nix paths are copied into the world readable Nix store. + </para> + </warning> + </section> + + <section xml:id="module-services-keycloak-themes"> + <title>Themes</title> + <para> + You can package custom themes and make them visible to Keycloak via + <xref linkend="opt-services.keycloak.themes" /> + option. See the <link xlink:href="https://www.keycloak.org/docs/latest/server_development/#_themes"> + Themes section of the Keycloak Server Development Guide</link> + and respective NixOS option description for more information. + </para> + </section> + + <section xml:id="module-services-keycloak-extra-config"> + <title>Additional configuration</title> + <para> + Additional Keycloak configuration options, for which no + explicit <productname>NixOS</productname> options are provided, + can be set in <xref linkend="opt-services.keycloak.extraConfig" />. + </para> + + <para> + Options are expressed as a Nix attribute set which matches the + structure of the jboss-cli configuration. The configuration is + effectively overlayed on top of the default configuration + shipped with Keycloak. To remove existing nodes and undefine + attributes from the default configuration, set them to + <literal>null</literal>. + </para> + <para> + For example, the following script, which removes the hostname + provider <literal>default</literal>, adds the deprecated + hostname provider <literal>fixed</literal> and defines it the + default: + +<programlisting> +/subsystem=keycloak-server/spi=hostname/provider=default:remove() +/subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" }) +/subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed") +</programlisting> + + would be expressed as + +<programlisting> +services.keycloak.extraConfig = { + "subsystem=keycloak-server" = { + "spi=hostname" = { + "provider=default" = null; + "provider=fixed" = { + enabled = true; + properties.hostname = "keycloak.example.com"; + }; + default-provider = "fixed"; + }; + }; +}; +</programlisting> + </para> + <para> + You can discover available options by using the <link + xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link> + program and by referring to the <link + xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak + Server Installation and Configuration Guide</link>. + </para> + </section> + + <section xml:id="module-services-keycloak-example-config"> + <title>Example configuration</title> + <para> + A basic configuration with some custom settings could look like this: +<programlisting> +services.keycloak = { + <link linkend="opt-services.keycloak.enable">enable</link> = true; + <link linkend="opt-services.keycloak.initialAdminPassword">initialAdminPassword</link> = "e6Wcm0RrtegMEHl"; # change on first login + <link linkend="opt-services.keycloak.frontendUrl">frontendUrl</link> = "https://keycloak.example.com/auth"; + <link linkend="opt-services.keycloak.forceBackendUrlToFrontendUrl">forceBackendUrlToFrontendUrl</link> = true; + <link linkend="opt-services.keycloak.sslCertificate">sslCertificate</link> = "/run/keys/ssl_cert"; + <link linkend="opt-services.keycloak.sslCertificateKey">sslCertificateKey</link> = "/run/keys/ssl_key"; + <link linkend="opt-services.keycloak.database.passwordFile">database.passwordFile</link> = "/run/keys/db_password"; +}; +</programlisting> + </para> + + </section> + </chapter> diff --git a/nixos/modules/services/web-apps/lemmy.md b/nixos/modules/services/web-apps/lemmy.md new file mode 100644 index 00000000000..e6599cd843e --- /dev/null +++ b/nixos/modules/services/web-apps/lemmy.md @@ -0,0 +1,34 @@ +# Lemmy {#module-services-lemmy} + +Lemmy is a federated alternative to reddit in rust. + +## Quickstart {#module-services-lemmy-quickstart} + +the minimum to start lemmy is + +```nix +services.lemmy = { + enable = true; + settings = { + hostname = "lemmy.union.rocks"; + database.createLocally = true; + }; + jwtSecretPath = "/run/secrets/lemmyJwt"; + caddy.enable = true; +} +``` + +(note that you can use something like agenix to get your secret jwt to the specified path) + +this will start the backend on port 8536 and the frontend on port 1234. +It will expose your instance with a caddy reverse proxy to the hostname you've provided. +Postgres will be initialized on that same instance automatically. + +## Usage {#module-services-lemmy-usage} + +On first connection you will be asked to define an admin user. + +## Missing {#module-services-lemmy-missing} + +- Exposing with nginx is not implemented yet. +- This has been tested using a local database with a unix socket connection. Using different database settings will likely require modifications diff --git a/nixos/modules/services/web-apps/lemmy.nix b/nixos/modules/services/web-apps/lemmy.nix new file mode 100644 index 00000000000..7cd2357c455 --- /dev/null +++ b/nixos/modules/services/web-apps/lemmy.nix @@ -0,0 +1,236 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.lemmy; + settingsFormat = pkgs.formats.json { }; +in +{ + meta.maintainers = with maintainers; [ happysalada ]; + # Don't edit the docbook xml directly, edit the md and generate it: + # `pandoc lemmy.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > lemmy.xml` + meta.doc = ./lemmy.xml; + + options.services.lemmy = { + + enable = mkEnableOption "lemmy a federated alternative to reddit in rust"; + + jwtSecretPath = mkOption { + type = types.path; + description = "Path to read the jwt secret from."; + }; + + ui = { + port = mkOption { + type = types.port; + default = 1234; + description = "Port where lemmy-ui should listen for incoming requests."; + }; + }; + + caddy.enable = mkEnableOption "exposing lemmy with the caddy reverse proxy"; + + settings = mkOption { + default = { }; + description = "Lemmy configuration"; + + type = types.submodule { + freeformType = settingsFormat.type; + + options.hostname = mkOption { + type = types.str; + default = null; + description = "The domain name of your instance (eg 'lemmy.ml')."; + }; + + options.port = mkOption { + type = types.port; + default = 8536; + description = "Port where lemmy should listen for incoming requests."; + }; + + options.federation = { + enabled = mkEnableOption "activitypub federation"; + }; + + options.captcha = { + enabled = mkOption { + type = types.bool; + default = true; + description = "Enable Captcha."; + }; + difficulty = mkOption { + type = types.enum [ "easy" "medium" "hard" ]; + default = "medium"; + description = "The difficultly of the captcha to solve."; + }; + }; + + options.database.createLocally = mkEnableOption "creation of database on the instance"; + + }; + }; + + }; + + config = + let + localPostgres = (cfg.settings.database.host == "localhost" || cfg.settings.database.host == "/run/postgresql"); + in + lib.mkIf cfg.enable { + services.lemmy.settings = (mapAttrs (name: mkDefault) + { + bind = "127.0.0.1"; + tls_enabled = true; + pictrs_url = with config.services.pict-rs; "http://${address}:${toString port}"; + actor_name_max_length = 20; + + rate_limit.message = 180; + rate_limit.message_per_second = 60; + rate_limit.post = 6; + rate_limit.post_per_second = 600; + rate_limit.register = 3; + rate_limit.register_per_second = 3600; + rate_limit.image = 6; + rate_limit.image_per_second = 3600; + } // { + database = mapAttrs (name: mkDefault) { + user = "lemmy"; + host = "/run/postgresql"; + port = 5432; + database = "lemmy"; + pool_size = 5; + }; + }); + + services.postgresql = mkIf localPostgres { + enable = mkDefault true; + }; + + services.pict-rs.enable = true; + + services.caddy = mkIf cfg.caddy.enable { + enable = mkDefault true; + virtualHosts."${cfg.settings.hostname}" = { + extraConfig = '' + handle_path /static/* { + root * ${pkgs.lemmy-ui}/dist + file_server + } + @for_backend { + path /api/* /pictrs/* feeds/* nodeinfo/* + } + handle @for_backend { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + @post { + method POST + } + handle @post { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + @jsonld { + header Accept "application/activity+json" + header Accept "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + } + handle @jsonld { + reverse_proxy 127.0.0.1:${toString cfg.settings.port} + } + handle { + reverse_proxy 127.0.0.1:${toString cfg.ui.port} + } + ''; + }; + }; + + assertions = [{ + assertion = cfg.settings.database.createLocally -> localPostgres; + message = "if you want to create the database locally, you need to use a local database"; + }]; + + systemd.services.lemmy = { + description = "Lemmy server"; + + environment = { + LEMMY_CONFIG_LOCATION = "/run/lemmy/config.hjson"; + + # Verify how this is used, and don't put the password in the nix store + LEMMY_DATABASE_URL = with cfg.settings.database;"postgres:///${database}?host=${host}"; + }; + + documentation = [ + "https://join-lemmy.org/docs/en/administration/from_scratch.html" + "https://join-lemmy.org/docs" + ]; + + wantedBy = [ "multi-user.target" ]; + + after = [ "pict-rs.service " ] ++ lib.optionals cfg.settings.database.createLocally [ "lemmy-postgresql.service" ]; + + requires = lib.optionals cfg.settings.database.createLocally [ "lemmy-postgresql.service" ]; + + # script is needed here since loadcredential is not accessible on ExecPreStart + script = '' + ${pkgs.coreutils}/bin/install -m 600 ${settingsFormat.generate "config.hjson" cfg.settings} /run/lemmy/config.hjson + jwtSecret="$(< $CREDENTIALS_DIRECTORY/jwt_secret )" + ${pkgs.jq}/bin/jq ".jwt_secret = \"$jwtSecret\"" /run/lemmy/config.hjson | ${pkgs.moreutils}/bin/sponge /run/lemmy/config.hjson + ${pkgs.lemmy-server}/bin/lemmy_server + ''; + + serviceConfig = { + DynamicUser = true; + RuntimeDirectory = "lemmy"; + LoadCredential = "jwt_secret:${cfg.jwtSecretPath}"; + }; + }; + + systemd.services.lemmy-ui = { + description = "Lemmy ui"; + + environment = { + LEMMY_UI_HOST = "127.0.0.1:${toString cfg.ui.port}"; + LEMMY_INTERNAL_HOST = "127.0.0.1:${toString cfg.settings.port}"; + LEMMY_EXTERNAL_HOST = cfg.settings.hostname; + LEMMY_HTTPS = "false"; + }; + + documentation = [ + "https://join-lemmy.org/docs/en/administration/from_scratch.html" + "https://join-lemmy.org/docs" + ]; + + wantedBy = [ "multi-user.target" ]; + + after = [ "lemmy.service" ]; + + requires = [ "lemmy.service" ]; + + serviceConfig = { + DynamicUser = true; + WorkingDirectory = "${pkgs.lemmy-ui}"; + ExecStart = "${pkgs.nodejs}/bin/node ${pkgs.lemmy-ui}/dist/js/server.js"; + }; + }; + + systemd.services.lemmy-postgresql = mkIf cfg.settings.database.createLocally { + description = "Lemmy postgresql db"; + after = [ "postgresql.service" ]; + partOf = [ "lemmy.service" ]; + script = with cfg.settings.database; '' + PSQL() { + ${config.services.postgresql.package}/bin/psql --port=${toString cfg.settings.database.port} "$@" + } + # check if the database already exists + if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${database} ; then + PSQL -tAc "CREATE ROLE ${user} WITH LOGIN;" + PSQL -tAc "CREATE DATABASE ${database} WITH OWNER ${user};" + fi + ''; + serviceConfig = { + User = config.services.postgresql.superUser; + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + }; + +} diff --git a/nixos/modules/services/web-apps/lemmy.xml b/nixos/modules/services/web-apps/lemmy.xml new file mode 100644 index 00000000000..0be9fb8aefa --- /dev/null +++ b/nixos/modules/services/web-apps/lemmy.xml @@ -0,0 +1,56 @@ +<chapter xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" xml:id="module-services-lemmy"> + <title>Lemmy</title> + <para> + Lemmy is a federated alternative to reddit in rust. + </para> + <section xml:id="module-services-lemmy-quickstart"> + <title>Quickstart</title> + <para> + the minimum to start lemmy is + </para> + <programlisting language="bash"> +services.lemmy = { + enable = true; + settings = { + hostname = "lemmy.union.rocks"; + database.createLocally = true; + }; + jwtSecretPath = "/run/secrets/lemmyJwt"; + caddy.enable = true; +} +</programlisting> + <para> + (note that you can use something like agenix to get your secret + jwt to the specified path) + </para> + <para> + this will start the backend on port 8536 and the frontend on port + 1234. It will expose your instance with a caddy reverse proxy to + the hostname you’ve provided. Postgres will be initialized on that + same instance automatically. + </para> + </section> + <section xml:id="module-services-lemmy-usage"> + <title>Usage</title> + <para> + On first connection you will be asked to define an admin user. + </para> + </section> + <section xml:id="module-services-lemmy-missing"> + <title>Missing</title> + <itemizedlist spacing="compact"> + <listitem> + <para> + Exposing with nginx is not implemented yet. + </para> + </listitem> + <listitem> + <para> + This has been tested using a local database with a unix socket + connection. Using different database settings will likely + require modifications + </para> + </listitem> + </itemizedlist> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/limesurvey.nix b/nixos/modules/services/web-apps/limesurvey.nix new file mode 100644 index 00000000000..5ccd742a303 --- /dev/null +++ b/nixos/modules/services/web-apps/limesurvey.nix @@ -0,0 +1,280 @@ +{ config, lib, pkgs, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption; + inherit (lib) literalExpression mapAttrs optional optionalString types; + + cfg = config.services.limesurvey; + fpm = config.services.phpfpm.pools.limesurvey; + + user = "limesurvey"; + group = config.services.httpd.group; + stateDir = "/var/lib/limesurvey"; + + pkg = pkgs.limesurvey; + + configType = with types; oneOf [ (attrsOf configType) str int bool ] // { + description = "limesurvey config type (str, int, bool or attribute set thereof)"; + }; + + limesurveyConfig = pkgs.writeText "config.php" '' + <?php + return json_decode('${builtins.toJSON cfg.config}', true); + ?> + ''; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + +in +{ + # interface + + options.services.limesurvey = { + enable = mkEnableOption "Limesurvey web application."; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" "odbc" "mssql" ]; + example = "pgsql"; + default = "mysql"; + description = "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.int; + default = if cfg.database.type == "pgsql" then 5442 else 3306; + defaultText = literalExpression "3306"; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "limesurvey"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "limesurvey"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/limesurvey-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = + if mysqlLocal then "/run/mysqld/mysqld.sock" + else if pgsqlLocal then "/run/postgresql" + else null + ; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = cfg.database.type == "mysql"; + defaultText = literalExpression "true"; + description = '' + Create the database and database user locally. + This currently only applies if database type "mysql" is selected. + ''; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "survey.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting <literal>services.httpd.virtualHosts.<name></literal>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the LimeSurvey PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + config = mkOption { + type = configType; + default = {}; + description = '' + LimeSurvey configuration. Refer to + <link xlink:href="https://manual.limesurvey.org/Optional_settings"/> + for details on supported values. + ''; + }; + }; + + # implementation + + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.type == "mysql"; + message = "services.limesurvey.createLocally is currently only supported for database type 'mysql'"; + } + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.limesurvey.database.user must be set to ${user} if services.limesurvey.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.socket != null; + message = "services.limesurvey.database.socket must be set if services.limesurvey.database.createLocally is set to true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.limesurvey.database.createLocally is set to true"; + } + ]; + + services.limesurvey.config = mapAttrs (name: mkDefault) { + runtimePath = "${stateDir}/tmp/runtime"; + components = { + db = { + connectionString = "${cfg.database.type}:dbname=${cfg.database.name};host=${if pgsqlLocal then cfg.database.socket else cfg.database.host};port=${toString cfg.database.port}" + + optionalString mysqlLocal ";socket=${cfg.database.socket}"; + username = cfg.database.user; + password = mkIf (cfg.database.passwordFile != null) "file_get_contents(\"${toString cfg.database.passwordFile}\");"; + tablePrefix = "limesurvey_"; + }; + assetManager.basePath = "${stateDir}/tmp/assets"; + urlManager = { + urlFormat = "path"; + showScriptName = false; + }; + }; + config = { + tempdir = "${stateDir}/tmp"; + uploaddir = "${stateDir}/upload"; + force_ssl = mkIf (cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL) "on"; + config.defaultlang = "en"; + }; + }; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "SELECT, CREATE, INSERT, UPDATE, DELETE, ALTER, DROP, INDEX"; + }; + } + ]; + }; + + services.phpfpm.pools.limesurvey = { + inherit user group; + phpEnv.LIMESURVEY_CONFIG = "${limesurveyConfig}"; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${pkg}/share/limesurvey"; + extraConfig = '' + Alias "/tmp" "${stateDir}/tmp" + <Directory "${stateDir}"> + AllowOverride all + Require all granted + Options -Indexes +FollowSymlinks + </Directory> + + Alias "/upload" "${stateDir}/upload" + <Directory "${stateDir}/upload"> + AllowOverride all + Require all granted + Options -Indexes + </Directory> + + <Directory "${pkg}/share/limesurvey"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + + AllowOverride all + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + systemd.tmpfiles.rules = [ + "d ${stateDir} 0750 ${user} ${group} - -" + "d ${stateDir}/tmp 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/assets 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/runtime 0750 ${user} ${group} - -" + "d ${stateDir}/tmp/upload 0750 ${user} ${group} - -" + "C ${stateDir}/upload 0750 ${user} ${group} - ${pkg}/share/limesurvey/upload" + ]; + + systemd.services.limesurvey-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-limesurvey.service" ]; + after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + environment.LIMESURVEY_CONFIG = limesurveyConfig; + script = '' + # update or install the database as required + ${pkgs.php}/bin/php ${pkg}/share/limesurvey/application/commands/console.php updatedb || \ + ${pkgs.php}/bin/php ${pkg}/share/limesurvey/application/commands/console.php install admin password admin admin@example.com verbose + ''; + serviceConfig = { + User = user; + Group = group; + Type = "oneshot"; + }; + }; + + systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + + }; +} diff --git a/nixos/modules/services/web-apps/mastodon.nix b/nixos/modules/services/web-apps/mastodon.nix new file mode 100644 index 00000000000..8208c85bfd7 --- /dev/null +++ b/nixos/modules/services/web-apps/mastodon.nix @@ -0,0 +1,636 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.mastodon; + # We only want to create a database if we're actually going to connect to it. + databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "/run/postgresql"; + + env = { + RAILS_ENV = "production"; + NODE_ENV = "production"; + + # mastodon-web concurrency. + WEB_CONCURRENCY = toString cfg.webProcesses; + MAX_THREADS = toString cfg.webThreads; + + # mastodon-streaming concurrency. + STREAMING_CLUSTER_NUM = toString cfg.streamingProcesses; + + DB_USER = cfg.database.user; + + REDIS_HOST = cfg.redis.host; + REDIS_PORT = toString(cfg.redis.port); + DB_HOST = cfg.database.host; + DB_PORT = toString(cfg.database.port); + DB_NAME = cfg.database.name; + LOCAL_DOMAIN = cfg.localDomain; + SMTP_SERVER = cfg.smtp.host; + SMTP_PORT = toString(cfg.smtp.port); + SMTP_FROM_ADDRESS = cfg.smtp.fromAddress; + PAPERCLIP_ROOT_PATH = "/var/lib/mastodon/public-system"; + PAPERCLIP_ROOT_URL = "/system"; + ES_ENABLED = if (cfg.elasticsearch.host != null) then "true" else "false"; + ES_HOST = cfg.elasticsearch.host; + ES_PORT = toString(cfg.elasticsearch.port); + + TRUSTED_PROXY_IP = cfg.trustedProxy; + } + // (if cfg.smtp.authenticate then { SMTP_LOGIN = cfg.smtp.user; } else {}) + // cfg.extraConfig; + + systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@mount" "@obsolete" "@privileged" "@setuid" ]; + + cfgService = { + # User and group + User = cfg.user; + Group = cfg.group; + # State directory and mode + StateDirectory = "mastodon"; + StateDirectoryMode = "0750"; + # Logs directory and mode + LogsDirectory = "mastodon"; + LogsDirectoryMode = "0750"; + # Proc filesystem + ProcSubset = "pid"; + ProtectProc = "invisible"; + # Access write directories + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = false; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + }; + + envFile = pkgs.writeText "mastodon.env" (lib.concatMapStrings (s: s + "\n") ( + (lib.concatLists (lib.mapAttrsToList (name: value: + if value != null then [ + "${name}=\"${toString value}\"" + ] else [] + ) env)))); + + mastodonEnv = pkgs.writeShellScriptBin "mastodon-env" '' + set -a + export RAILS_ROOT="${cfg.package}" + source "${envFile}" + source /var/lib/mastodon/.secrets_env + eval -- "\$@" + ''; + +in { + + options = { + services.mastodon = { + enable = lib.mkEnableOption "Mastodon, a federated social network server"; + + configureNginx = lib.mkOption { + description = '' + Configure nginx as a reverse proxy for mastodon. + Note that this makes some assumptions on your setup, and sets settings that will + affect other virtualHosts running on your nginx instance, if any. + Alternatively you can configure a reverse-proxy of your choice to serve these paths: + + <code>/ -> $(nix-instantiate --eval '<nixpkgs>' -A mastodon.outPath)/public</code> + + <code>/ -> 127.0.0.1:{{ webPort }} </code>(If there was no file in the directory above.) + + <code>/system/ -> /var/lib/mastodon/public-system/</code> + + <code>/api/v1/streaming/ -> 127.0.0.1:{{ streamingPort }}</code> + + Make sure that websockets are forwarded properly. You might want to set up caching + of some requests. Take a look at mastodon's provided nginx configuration at + <code>https://github.com/tootsuite/mastodon/blob/master/dist/nginx.conf</code>. + ''; + type = lib.types.bool; + default = false; + }; + + user = lib.mkOption { + description = '' + User under which mastodon runs. If it is set to "mastodon", + that user will be created, otherwise it should be set to the + name of a user created elsewhere. In both cases, + <package>mastodon</package> and a package containing only + the shell script <code>mastodon-env</code> will be added to + the user's package set. To run a command from + <package>mastodon</package> such as <code>tootctl</code> + with the environment configured by this module use + <code>mastodon-env</code>, as in: + + <code>mastodon-env tootctl accounts create newuser --email newuser@example.com</code> + ''; + type = lib.types.str; + default = "mastodon"; + }; + + group = lib.mkOption { + description = '' + Group under which mastodon runs. + ''; + type = lib.types.str; + default = "mastodon"; + }; + + streamingPort = lib.mkOption { + description = "TCP port used by the mastodon-streaming service."; + type = lib.types.port; + default = 55000; + }; + streamingProcesses = lib.mkOption { + description = '' + Processes used by the mastodon-streaming service. + Defaults to the number of CPU cores minus one. + ''; + type = lib.types.nullOr lib.types.int; + default = null; + }; + + webPort = lib.mkOption { + description = "TCP port used by the mastodon-web service."; + type = lib.types.port; + default = 55001; + }; + webProcesses = lib.mkOption { + description = "Processes used by the mastodon-web service."; + type = lib.types.int; + default = 2; + }; + webThreads = lib.mkOption { + description = "Threads per process used by the mastodon-web service."; + type = lib.types.int; + default = 5; + }; + + sidekiqPort = lib.mkOption { + description = "TCP port used by the mastodon-sidekiq service."; + type = lib.types.port; + default = 55002; + }; + sidekiqThreads = lib.mkOption { + description = "Worker threads used by the mastodon-sidekiq service."; + type = lib.types.int; + default = 25; + }; + + vapidPublicKeyFile = lib.mkOption { + description = '' + Path to file containing the public key used for Web Push + Voluntary Application Server Identification. A new keypair can + be generated by running: + + <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys</code> + + If <option>mastodon.vapidPrivateKeyFile</option>does not + exist, it and this file will be created with a new keypair. + ''; + default = "/var/lib/mastodon/secrets/vapid-public-key"; + type = lib.types.str; + }; + + localDomain = lib.mkOption { + description = "The domain serving your Mastodon instance."; + example = "social.example.org"; + type = lib.types.str; + }; + + secretKeyBaseFile = lib.mkOption { + description = '' + Path to file containing the secret key base. + A new secret key base can be generated by running: + + <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret</code> + + If this file does not exist, it will be created with a new secret key base. + ''; + default = "/var/lib/mastodon/secrets/secret-key-base"; + type = lib.types.str; + }; + + otpSecretFile = lib.mkOption { + description = '' + Path to file containing the OTP secret. + A new OTP secret can be generated by running: + + <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake secret</code> + + If this file does not exist, it will be created with a new OTP secret. + ''; + default = "/var/lib/mastodon/secrets/otp-secret"; + type = lib.types.str; + }; + + vapidPrivateKeyFile = lib.mkOption { + description = '' + Path to file containing the private key used for Web Push + Voluntary Application Server Identification. A new keypair can + be generated by running: + + <code>nix build -f '<nixpkgs>' mastodon; cd result; bin/rake webpush:generate_keys</code> + + If this file does not exist, it will be created with a new + private key. + ''; + default = "/var/lib/mastodon/secrets/vapid-private-key"; + type = lib.types.str; + }; + + trustedProxy = lib.mkOption { + description = '' + You need to set it to the IP from which your reverse proxy sends requests to Mastodon's web process, + otherwise Mastodon will record the reverse proxy's own IP as the IP of all requests, which would be + bad because IP addresses are used for important rate limits and security functions. + ''; + type = lib.types.str; + default = "127.0.0.1"; + }; + + enableUnixSocket = lib.mkOption { + description = '' + Instead of binding to an IP address like 127.0.0.1, you may bind to a Unix socket. This variable + is process-specific, e.g. you need different values for every process, and it works for both web (Puma) + processes and streaming API (Node.js) processes. + ''; + type = lib.types.bool; + default = true; + }; + + redis = { + createLocally = lib.mkOption { + description = "Configure local Redis server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + host = lib.mkOption { + description = "Redis host."; + type = lib.types.str; + default = "127.0.0.1"; + }; + + port = lib.mkOption { + description = "Redis port."; + type = lib.types.port; + default = 6379; + }; + }; + + database = { + createLocally = lib.mkOption { + description = "Configure local PostgreSQL database server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "/run/postgresql"; + example = "192.168.23.42"; + description = "Database host address or unix socket."; + }; + + port = lib.mkOption { + type = lib.types.int; + default = 5432; + description = "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "mastodon"; + description = "Database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "mastodon"; + description = "Database user."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = "/var/lib/mastodon/secrets/db-password"; + example = "/run/keys/mastodon-db-password"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + }; + + smtp = { + createLocally = lib.mkOption { + description = "Configure local Postfix SMTP server for Mastodon."; + type = lib.types.bool; + default = true; + }; + + authenticate = lib.mkOption { + description = "Authenticate with the SMTP server using username and password."; + type = lib.types.bool; + default = false; + }; + + host = lib.mkOption { + description = "SMTP host used when sending emails to users."; + type = lib.types.str; + default = "127.0.0.1"; + }; + + port = lib.mkOption { + description = "SMTP port used when sending emails to users."; + type = lib.types.port; + default = 25; + }; + + fromAddress = lib.mkOption { + description = ''"From" address used when sending Emails to users.''; + type = lib.types.str; + }; + + user = lib.mkOption { + description = "SMTP login name."; + type = lib.types.str; + }; + + passwordFile = lib.mkOption { + description = '' + Path to file containing the SMTP password. + ''; + default = "/var/lib/mastodon/secrets/smtp-password"; + example = "/run/keys/mastodon-smtp-password"; + type = lib.types.str; + }; + }; + + elasticsearch = { + host = lib.mkOption { + description = '' + Elasticsearch host. + If it is not null, Elasticsearch full text search will be enabled. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; + + port = lib.mkOption { + description = "Elasticsearch port."; + type = lib.types.port; + default = 9200; + }; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.mastodon; + defaultText = lib.literalExpression "pkgs.mastodon"; + description = "Mastodon package to use."; + }; + + extraConfig = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = '' + Extra environment variables to pass to all mastodon services. + ''; + }; + + automaticMigrations = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Do automatic database migrations. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = databaseActuallyCreateLocally -> (cfg.user == cfg.database.user); + message = ''For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer authentication (services.mastodon.database.host == "/run/postgresql") to work services.mastodon.user and services.mastodon.database.user must be identical.''; + } + ]; + + systemd.services.mastodon-init-dirs = { + script = '' + umask 077 + + if ! test -f ${cfg.secretKeyBaseFile}; then + mkdir -p $(dirname ${cfg.secretKeyBaseFile}) + bin/rake secret > ${cfg.secretKeyBaseFile} + fi + if ! test -f ${cfg.otpSecretFile}; then + mkdir -p $(dirname ${cfg.otpSecretFile}) + bin/rake secret > ${cfg.otpSecretFile} + fi + if ! test -f ${cfg.vapidPrivateKeyFile}; then + mkdir -p $(dirname ${cfg.vapidPrivateKeyFile}) $(dirname ${cfg.vapidPublicKeyFile}) + keypair=$(bin/rake webpush:generate_keys) + echo $keypair | grep --only-matching "Private -> [^ ]\+" | sed 's/^Private -> //' > ${cfg.vapidPrivateKeyFile} + echo $keypair | grep --only-matching "Public -> [^ ]\+" | sed 's/^Public -> //' > ${cfg.vapidPublicKeyFile} + fi + + cat > /var/lib/mastodon/.secrets_env <<EOF + SECRET_KEY_BASE="$(cat ${cfg.secretKeyBaseFile})" + OTP_SECRET="$(cat ${cfg.otpSecretFile})" + VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})" + VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})" + DB_PASS="$(cat ${cfg.database.passwordFile})" + '' + (if cfg.smtp.authenticate then '' + SMTP_PASSWORD="$(cat ${cfg.smtp.passwordFile})" + '' else "") + '' + EOF + ''; + environment = env; + serviceConfig = { + Type = "oneshot"; + WorkingDirectory = cfg.package; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ]; + } // cfgService; + + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + }; + + systemd.services.mastodon-init-db = lib.mkIf cfg.automaticMigrations { + script = '' + if [ `psql ${cfg.database.name} -c \ + "select count(*) from pg_class c \ + join pg_namespace s on s.oid = c.relnamespace \ + where s.nspname not in ('pg_catalog', 'pg_toast', 'information_schema') \ + and s.nspname not like 'pg_temp%';" | sed -n 3p` -eq 0 ]; then + SAFETY_ASSURED=1 rails db:schema:load + rails db:seed + else + rails db:migrate + fi + ''; + path = [ cfg.package pkgs.postgresql ]; + environment = env; + serviceConfig = { + Type = "oneshot"; + EnvironmentFile = "/var/lib/mastodon/.secrets_env"; + WorkingDirectory = cfg.package; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ]; + } // cfgService; + after = [ "mastodon-init-dirs.service" "network.target" ] ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []); + wantedBy = [ "multi-user.target" ]; + }; + + systemd.services.mastodon-streaming = { + after = [ "network.target" ] + ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []) + ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]); + description = "Mastodon streaming"; + wantedBy = [ "multi-user.target" ]; + environment = env // (if cfg.enableUnixSocket + then { SOCKET = "/run/mastodon-streaming/streaming.socket"; } + else { PORT = toString(cfg.streamingPort); } + ); + serviceConfig = { + ExecStart = "${cfg.package}/run-streaming.sh"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = "/var/lib/mastodon/.secrets_env"; + WorkingDirectory = cfg.package; + # Runtime directory and mode + RuntimeDirectory = "mastodon-streaming"; + RuntimeDirectoryMode = "0750"; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@memlock" "@resources" ])) "pipe" "pipe2" ]; + } // cfgService; + }; + + systemd.services.mastodon-web = { + after = [ "network.target" ] + ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []) + ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]); + description = "Mastodon web"; + wantedBy = [ "multi-user.target" ]; + environment = env // (if cfg.enableUnixSocket + then { SOCKET = "/run/mastodon-web/web.socket"; } + else { PORT = toString(cfg.webPort); } + ); + serviceConfig = { + ExecStart = "${cfg.package}/bin/puma -C config/puma.rb"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = "/var/lib/mastodon/.secrets_env"; + WorkingDirectory = cfg.package; + # Runtime directory and mode + RuntimeDirectory = "mastodon-web"; + RuntimeDirectoryMode = "0750"; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ]; + } // cfgService; + path = with pkgs; [ file imagemagick ffmpeg ]; + }; + + systemd.services.mastodon-sidekiq = { + after = [ "network.target" ] + ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []) + ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]); + description = "Mastodon sidekiq"; + wantedBy = [ "multi-user.target" ]; + environment = env // { + PORT = toString(cfg.sidekiqPort); + DB_POOL = toString cfg.sidekiqThreads; + }; + serviceConfig = { + ExecStart = "${cfg.package}/bin/sidekiq -c ${toString cfg.sidekiqThreads} -r ${cfg.package}"; + Restart = "always"; + RestartSec = 20; + EnvironmentFile = "/var/lib/mastodon/.secrets_env"; + WorkingDirectory = cfg.package; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ]; + } // cfgService; + path = with pkgs; [ file imagemagick ffmpeg ]; + }; + + services.nginx = lib.mkIf cfg.configureNginx { + enable = true; + recommendedProxySettings = true; # required for redirections to work + virtualHosts."${cfg.localDomain}" = { + root = "${cfg.package}/public/"; + forceSSL = true; # mastodon only supports https + enableACME = true; + + locations."/system/".alias = "/var/lib/mastodon/public-system/"; + + locations."/" = { + tryFiles = "$uri @proxy"; + }; + + locations."@proxy" = { + proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-web/web.socket" else "http://127.0.0.1:${toString(cfg.webPort)}"); + proxyWebsockets = true; + }; + + locations."/api/v1/streaming/" = { + proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-streaming/streaming.socket" else "http://127.0.0.1:${toString(cfg.streamingPort)}/"); + proxyWebsockets = true; + }; + }; + }; + + services.postfix = lib.mkIf (cfg.smtp.createLocally && cfg.smtp.host == "127.0.0.1") { + enable = true; + hostname = lib.mkDefault "${cfg.localDomain}"; + }; + services.redis = lib.mkIf (cfg.redis.createLocally && cfg.redis.host == "127.0.0.1") { + enable = true; + }; + services.postgresql = lib.mkIf databaseActuallyCreateLocally { + enable = true; + ensureUsers = [ + { + name = cfg.database.user; + ensurePermissions."DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; + } + ]; + ensureDatabases = [ cfg.database.name ]; + }; + + users.users = lib.mkMerge [ + (lib.mkIf (cfg.user == "mastodon") { + mastodon = { + isSystemUser = true; + home = cfg.package; + inherit (cfg) group; + }; + }) + (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package mastodonEnv ]) + ]; + + users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user; + }; + + meta.maintainers = with lib.maintainers; [ happy-river erictapen ]; + +} diff --git a/nixos/modules/services/web-apps/matomo-doc.xml b/nixos/modules/services/web-apps/matomo-doc.xml new file mode 100644 index 00000000000..69d1170e452 --- /dev/null +++ b/nixos/modules/services/web-apps/matomo-doc.xml @@ -0,0 +1,107 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-matomo"> + <title>Matomo</title> + <para> + Matomo is a real-time web analytics application. This module configures + php-fpm as backend for Matomo, optionally configuring an nginx vhost as well. + </para> + <para> + An automatic setup is not suported by Matomo, so you need to configure Matomo + itself in the browser-based Matomo setup. + </para> + <section xml:id="module-services-matomo-database-setup"> + <title>Database Setup</title> + + <para> + You also need to configure a MariaDB or MySQL database and -user for Matomo + yourself, and enter those credentials in your browser. You can use + passwordless database authentication via the UNIX_SOCKET authentication + plugin with the following SQL commands: +<programlisting> +# For MariaDB +INSTALL PLUGIN unix_socket SONAME 'auth_socket'; +CREATE DATABASE matomo; +CREATE USER 'matomo'@'localhost' IDENTIFIED WITH unix_socket; +GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost'; + +# For MySQL +INSTALL PLUGIN auth_socket SONAME 'auth_socket.so'; +CREATE DATABASE matomo; +CREATE USER 'matomo'@'localhost' IDENTIFIED WITH auth_socket; +GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost'; +</programlisting> + Then fill in <literal>matomo</literal> as database user and database name, + and leave the password field blank. This authentication works by allowing + only the <literal>matomo</literal> unix user to authenticate as the + <literal>matomo</literal> database user (without needing a password), but no + other users. For more information on passwordless login, see + <link xlink:href="https://mariadb.com/kb/en/mariadb/unix_socket-authentication-plugin/" />. + </para> + + <para> + Of course, you can use password based authentication as well, e.g. when the + database is not on the same host. + </para> + </section> + <section xml:id="module-services-matomo-archive-processing"> + <title>Archive Processing</title> + + <para> + This module comes with the systemd service + <literal>matomo-archive-processing.service</literal> and a timer that + automatically triggers archive processing every hour. This means that you + can safely + <link xlink:href="https://matomo.org/docs/setup-auto-archiving/#disable-browser-triggers-for-matomo-archiving-and-limit-matomo-reports-to-updating-every-hour"> + disable browser triggers for Matomo archiving </link> at + <literal>Administration > System > General Settings</literal>. + </para> + + <para> + With automatic archive processing, you can now also enable to + <link xlink:href="https://matomo.org/docs/privacy/#step-2-delete-old-visitors-logs"> + delete old visitor logs </link> at <literal>Administration > System > + Privacy</literal>, but make sure that you run <literal>systemctl start + matomo-archive-processing.service</literal> at least once without errors if + you have already collected data before, so that the reports get archived + before the source data gets deleted. + </para> + </section> + <section xml:id="module-services-matomo-backups"> + <title>Backup</title> + + <para> + You only need to take backups of your MySQL database and the + <filename>/var/lib/matomo/config/config.ini.php</filename> file. Use a user + in the <literal>matomo</literal> group or root to access the file. For more + information, see + <link xlink:href="https://matomo.org/faq/how-to-install/faq_138/" />. + </para> + </section> + <section xml:id="module-services-matomo-issues"> + <title>Issues</title> + + <itemizedlist> + <listitem> + <para> + Matomo will warn you that the JavaScript tracker is not writable. This is + because it's located in the read-only nix store. You can safely ignore + this, unless you need a plugin that needs JavaScript tracker access. + </para> + </listitem> + </itemizedlist> + </section> + <section xml:id="module-services-matomo-other-web-servers"> + <title>Using other Web Servers than nginx</title> + + <para> + You can use other web servers by forwarding calls for + <filename>index.php</filename> and <filename>piwik.php</filename> to the + <literal><link linkend="opt-services.phpfpm.pools._name_.socket">services.phpfpm.pools.<name>.socket</link></literal> fastcgi unix socket. You can use + the nginx configuration in the module code as a reference to what else + should be configured. + </para> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/matomo.nix b/nixos/modules/services/web-apps/matomo.nix new file mode 100644 index 00000000000..c6d4ed6d39d --- /dev/null +++ b/nixos/modules/services/web-apps/matomo.nix @@ -0,0 +1,335 @@ +{ config, lib, options, pkgs, ... }: +with lib; +let + cfg = config.services.matomo; + fpm = config.services.phpfpm.pools.${pool}; + + user = "matomo"; + dataDir = "/var/lib/${user}"; + deprecatedDataDir = "/var/lib/piwik"; + + pool = user; + phpExecutionUnit = "phpfpm-${pool}"; + databaseService = "mysql.service"; + + fqdn = if config.networking.domain != null then config.networking.fqdn else config.networking.hostName; + +in { + imports = [ + (mkRenamedOptionModule [ "services" "piwik" "enable" ] [ "services" "matomo" "enable" ]) + (mkRenamedOptionModule [ "services" "piwik" "webServerUser" ] [ "services" "matomo" "webServerUser" ]) + (mkRemovedOptionModule [ "services" "piwik" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings") + (mkRemovedOptionModule [ "services" "matomo" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings") + (mkRenamedOptionModule [ "services" "piwik" "nginx" ] [ "services" "matomo" "nginx" ]) + (mkRenamedOptionModule [ "services" "matomo" "periodicArchiveProcessingUrl" ] [ "services" "matomo" "hostname" ]) + ]; + + options = { + services.matomo = { + # NixOS PR for database setup: https://github.com/NixOS/nixpkgs/pull/6963 + # Matomo issue for automatic Matomo setup: https://github.com/matomo-org/matomo/issues/10257 + # TODO: find a nice way to do this when more NixOS MySQL and / or Matomo automatic setup stuff is implemented. + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable Matomo web analytics with php-fpm backend. + Either the nginx option or the webServerUser option is mandatory. + ''; + }; + + package = mkOption { + type = types.package; + description = '' + Matomo package for the service to use. + This can be used to point to newer releases from nixos-unstable, + as they don't get backported if they are not security-relevant. + ''; + default = pkgs.matomo; + defaultText = literalExpression "pkgs.matomo"; + }; + + webServerUser = mkOption { + type = types.nullOr types.str; + default = null; + example = "lighttpd"; + description = '' + Name of the web server user that forwards requests to <option>services.phpfpm.pools.<name>.socket</option> the fastcgi socket for Matomo if the nginx + option is not used. Either this option or the nginx option is mandatory. + If you want to use another webserver than nginx, you need to set this to that server's user + and pass fastcgi requests to `index.php`, `matomo.php` and `piwik.php` (legacy name) to this socket. + ''; + }; + + periodicArchiveProcessing = mkOption { + type = types.bool; + default = true; + description = '' + Enable periodic archive processing, which generates aggregated reports from the visits. + + This means that you can safely disable browser triggers for Matomo archiving, + and safely enable to delete old visitor logs. + Before deleting visitor logs, + make sure though that you run <literal>systemctl start matomo-archive-processing.service</literal> + at least once without errors if you have already collected data before. + ''; + }; + + hostname = mkOption { + type = types.str; + default = "${user}.${fqdn}"; + defaultText = literalExpression '' + if config.${options.networking.domain} != null + then "${user}.''${config.${options.networking.fqdn}}" + else "${user}.''${config.${options.networking.hostName}}" + ''; + example = "matomo.yourdomain.org"; + description = '' + URL of the host, without https prefix. You may want to change it if you + run Matomo on a different URL than matomo.yourdomain. + ''; + }; + + nginx = mkOption { + type = types.nullOr (types.submodule ( + recursiveUpdate + (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) + { + # enable encryption by default, + # as sensitive login and Matomo data should not be transmitted in clear text. + options.forceSSL.default = true; + options.enableACME.default = true; + } + ) + ); + default = null; + example = literalExpression '' + { + serverAliases = [ + "matomo.''${config.networking.domain}" + "stats.''${config.networking.domain}" + ]; + enableACME = false; + } + ''; + description = '' + With this option, you can customize an nginx virtualHost which already has sensible defaults for Matomo. + Either this option or the webServerUser option is mandatory. + Set this to {} to just enable the virtualHost if you don't need any customization. + If enabled, then by default, the <option>serverName</option> is + <literal>''${user}.''${config.networking.hostName}.''${config.networking.domain}</literal>, + SSL is active, and certificates are acquired via ACME. + If this is set to null (the default), no nginx virtualHost will be configured. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + warnings = mkIf (cfg.nginx != null && cfg.webServerUser != null) [ + "If services.matomo.nginx is set, services.matomo.nginx.webServerUser is ignored and should be removed." + ]; + + assertions = [ { + assertion = cfg.nginx != null || cfg.webServerUser != null; + message = "Either services.matomo.nginx or services.matomo.nginx.webServerUser is mandatory"; + }]; + + users.users.${user} = { + isSystemUser = true; + createHome = true; + home = dataDir; + group = user; + }; + users.groups.${user} = {}; + + systemd.services.matomo-setup-update = { + # everything needs to set up and up to date before Matomo php files are executed + requiredBy = [ "${phpExecutionUnit}.service" ]; + before = [ "${phpExecutionUnit}.service" ]; + # the update part of the script can only work if the database is already up and running + requires = [ databaseService ]; + after = [ databaseService ]; + path = [ cfg.package ]; + environment.PIWIK_USER_PATH = dataDir; + serviceConfig = { + Type = "oneshot"; + User = user; + # hide especially config.ini.php from other + UMask = "0007"; + # TODO: might get renamed to MATOMO_USER_PATH in future versions + # chown + chmod in preStart needs root + PermissionsStartOnly = true; + }; + + # correct ownership and permissions in case they're not correct anymore, + # e.g. after restoring from backup or moving from another system. + # Note that ${dataDir}/config/config.ini.php might contain the MySQL password. + preStart = '' + # migrate data from piwik to Matomo folder + if [ -d ${deprecatedDataDir} ]; then + echo "Migrating from ${deprecatedDataDir} to ${dataDir}" + mv -T ${deprecatedDataDir} ${dataDir} + fi + chown -R ${user}:${user} ${dataDir} + chmod -R ug+rwX,o-rwx ${dataDir} + + if [ -e ${dataDir}/current-package ]; then + CURRENT_PACKAGE=$(readlink ${dataDir}/current-package) + NEW_PACKAGE=${cfg.package} + if [ "$CURRENT_PACKAGE" != "$NEW_PACKAGE" ]; then + # keeping tmp arround between upgrades seems to bork stuff, so delete it + rm -rf ${dataDir}/tmp + fi + elif [ -e ${dataDir}/tmp ]; then + # upgrade from 4.4.1 + rm -rf ${dataDir}/tmp + fi + ln -sfT ${cfg.package} ${dataDir}/current-package + ''; + script = '' + # Use User-Private Group scheme to protect Matomo data, but allow administration / backup via 'matomo' group + # Copy config folder + chmod g+s "${dataDir}" + cp -r "${cfg.package}/share/config" "${dataDir}/" + mkdir -p "${dataDir}/misc" + chmod -R u+rwX,g+rwX,o-rwx "${dataDir}" + + # check whether user setup has already been done + if test -f "${dataDir}/config/config.ini.php"; then + # then execute possibly pending database upgrade + matomo-console core:update --yes + fi + ''; + }; + + # If this is run regularly via the timer, + # 'Browser trigger archiving' can be disabled in Matomo UI > Settings > General Settings. + systemd.services.matomo-archive-processing = { + description = "Archive Matomo reports"; + # the archiving can only work if the database is already up and running + requires = [ databaseService ]; + after = [ databaseService ]; + + # TODO: might get renamed to MATOMO_USER_PATH in future versions + environment.PIWIK_USER_PATH = dataDir; + serviceConfig = { + Type = "oneshot"; + User = user; + UMask = "0007"; + CPUSchedulingPolicy = "idle"; + IOSchedulingClass = "idle"; + ExecStart = "${cfg.package}/bin/matomo-console core:archive --url=https://${cfg.hostname}"; + }; + }; + + systemd.timers.matomo-archive-processing = mkIf cfg.periodicArchiveProcessing { + description = "Automatically archive Matomo reports every hour"; + + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "hourly"; + Persistent = "yes"; + AccuracySec = "10m"; + }; + }; + + systemd.services.${phpExecutionUnit} = { + # stop phpfpm on package upgrade, do database upgrade via matomo-setup-update, and then restart + restartTriggers = [ cfg.package ]; + # stop config.ini.php from getting written with read permission for others + serviceConfig.UMask = "0007"; + }; + + services.phpfpm.pools = let + # workaround for when both are null and need to generate a string, + # which is illegal, but as assertions apparently are being triggered *after* config generation, + # we have to avoid already throwing errors at this previous stage. + socketOwner = if (cfg.nginx != null) then config.services.nginx.user + else if (cfg.webServerUser != null) then cfg.webServerUser else ""; + in { + ${pool} = { + inherit user; + phpOptions = '' + error_log = 'stderr' + log_errors = on + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = socketOwner; + "listen.group" = "root"; + "listen.mode" = "0660"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = true; + }; + phpEnv.PIWIK_USER_PATH = dataDir; + }; + }; + + + services.nginx.virtualHosts = mkIf (cfg.nginx != null) { + # References: + # https://fralef.me/piwik-hardening-with-nginx-and-php-fpm.html + # https://github.com/perusio/piwik-nginx + "${cfg.hostname}" = mkMerge [ cfg.nginx { + # don't allow to override the root easily, as it will almost certainly break Matomo. + # disadvantage: not shown as default in docs. + root = mkForce "${cfg.package}/share"; + + # define locations here instead of as the submodule option's default + # so that they can easily be extended with additional locations if required + # without needing to redefine the Matomo ones. + # disadvantage: not shown as default in docs. + locations."/" = { + index = "index.php"; + }; + # allow index.php for webinterface + locations."= /index.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # allow matomo.php for tracking + locations."= /matomo.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # allow piwik.php for tracking (deprecated name) + locations."= /piwik.php".extraConfig = '' + fastcgi_pass unix:${fpm.socket}; + ''; + # Any other attempt to access any php files is forbidden + locations."~* ^.+\\.php$".extraConfig = '' + return 403; + ''; + # Disallow access to unneeded directories + # config and tmp are already removed + locations."~ ^/(?:core|lang|misc)/".extraConfig = '' + return 403; + ''; + # Disallow access to several helper files + locations."~* \\.(?:bat|git|ini|sh|txt|tpl|xml|md)$".extraConfig = '' + return 403; + ''; + # No crawling of this site for bots that obey robots.txt - no useful information here. + locations."= /robots.txt".extraConfig = '' + return 200 "User-agent: *\nDisallow: /\n"; + ''; + # let browsers cache matomo.js + locations."= /matomo.js".extraConfig = '' + expires 1M; + ''; + # let browsers cache piwik.js (deprecated name) + locations."= /piwik.js".extraConfig = '' + expires 1M; + ''; + }]; + }; + }; + + meta = { + doc = ./matomo-doc.xml; + maintainers = with lib.maintainers; [ florianjacob ]; + }; +} diff --git a/nixos/modules/services/web-apps/mattermost.nix b/nixos/modules/services/web-apps/mattermost.nix new file mode 100644 index 00000000000..2901f307dc5 --- /dev/null +++ b/nixos/modules/services/web-apps/mattermost.nix @@ -0,0 +1,344 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + + cfg = config.services.mattermost; + + database = "postgres://${cfg.localDatabaseUser}:${cfg.localDatabasePassword}@localhost:5432/${cfg.localDatabaseName}?sslmode=disable&connect_timeout=10"; + + postgresPackage = config.services.postgresql.package; + + createDb = { + statePath ? cfg.statePath, + localDatabaseUser ? cfg.localDatabaseUser, + localDatabasePassword ? cfg.localDatabasePassword, + localDatabaseName ? cfg.localDatabaseName, + useSudo ? true + }: '' + if ! test -e ${escapeShellArg "${statePath}/.db-created"}; then + ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} + ${postgresPackage}/bin/psql postgres -c \ + "CREATE ROLE ${localDatabaseUser} WITH LOGIN NOCREATEDB NOCREATEROLE ENCRYPTED PASSWORD '${localDatabasePassword}'" + ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} + ${postgresPackage}/bin/createdb \ + --owner ${escapeShellArg localDatabaseUser} ${escapeShellArg localDatabaseName} + touch ${escapeShellArg "${statePath}/.db-created"} + fi + ''; + + mattermostPluginDerivations = with pkgs; + map (plugin: stdenv.mkDerivation { + name = "mattermost-plugin"; + installPhase = '' + mkdir -p $out/share + cp ${plugin} $out/share/plugin.tar.gz + ''; + dontUnpack = true; + dontPatch = true; + dontConfigure = true; + dontBuild = true; + preferLocalBuild = true; + }) cfg.plugins; + + mattermostPlugins = with pkgs; + if mattermostPluginDerivations == [] then null + else stdenv.mkDerivation { + name = "${cfg.package.name}-plugins"; + nativeBuildInputs = [ + autoPatchelfHook + ] ++ mattermostPluginDerivations; + buildInputs = [ + cfg.package + ]; + installPhase = '' + mkdir -p $out/data/plugins + plugins=(${escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations)}) + for plugin in "''${plugins[@]}"; do + hash="$(sha256sum "$plugin" | cut -d' ' -f1)" + mkdir -p "$hash" + tar -C "$hash" -xzf "$plugin" + autoPatchelf "$hash" + GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/data/plugins/$hash.tar.gz" . + rm -rf "$hash" + done + ''; + + dontUnpack = true; + dontPatch = true; + dontConfigure = true; + dontBuild = true; + preferLocalBuild = true; + }; + + mattermostConfWithoutPlugins = recursiveUpdate + { ServiceSettings.SiteURL = cfg.siteUrl; + ServiceSettings.ListenAddress = cfg.listenAddress; + TeamSettings.SiteName = cfg.siteName; + SqlSettings.DriverName = "postgres"; + SqlSettings.DataSource = database; + PluginSettings.Directory = "${cfg.statePath}/plugins/server"; + PluginSettings.ClientDirectory = "${cfg.statePath}/plugins/client"; + } + cfg.extraConfig; + + mattermostConf = recursiveUpdate + mattermostConfWithoutPlugins + ( + if mattermostPlugins == null then {} + else { + PluginSettings = { + Enable = true; + }; + } + ); + + mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf); + +in + +{ + options = { + services.mattermost = { + enable = mkEnableOption "Mattermost chat server"; + + package = mkOption { + type = types.package; + default = pkgs.mattermost; + defaultText = "pkgs.mattermost"; + description = "Mattermost derivation to use."; + }; + + statePath = mkOption { + type = types.str; + default = "/var/lib/mattermost"; + description = "Mattermost working directory"; + }; + + siteUrl = mkOption { + type = types.str; + example = "https://chat.example.com"; + description = '' + URL this Mattermost instance is reachable under, without trailing slash. + ''; + }; + + siteName = mkOption { + type = types.str; + default = "Mattermost"; + description = "Name of this Mattermost site."; + }; + + listenAddress = mkOption { + type = types.str; + default = ":8065"; + example = "[::1]:8065"; + description = '' + Address and port this Mattermost instance listens to. + ''; + }; + + mutableConfig = mkOption { + type = types.bool; + default = false; + description = '' + Whether the Mattermost config.json is writeable by Mattermost. + + Most of the settings can be edited in the system console of + Mattermost if this option is enabled. A template config using + the options specified in services.mattermost will be generated + but won't be overwritten on changes or rebuilds. + + If this option is disabled, changes in the system console won't + be possible (default). If an config.json is present, it will be + overwritten! + ''; + }; + + preferNixConfig = mkOption { + type = types.bool; + default = false; + description = '' + If both mutableConfig and this option are set, the Nix configuration + will take precedence over any settings configured in the server + console. + ''; + }; + + extraConfig = mkOption { + type = types.attrs; + default = { }; + description = '' + Addtional configuration options as Nix attribute set in config.json schema. + ''; + }; + + plugins = mkOption { + type = types.listOf (types.oneOf [types.path types.package]); + default = []; + example = "[ ./com.github.moussetc.mattermost.plugin.giphy-2.0.0.tar.gz ]"; + description = '' + Plugins to add to the configuration. Overrides any installed if non-null. + This is a list of paths to .tar.gz files or derivations evaluating to + .tar.gz files. + ''; + }; + + localDatabaseCreate = mkOption { + type = types.bool; + default = true; + description = '' + Create a local PostgreSQL database for Mattermost automatically. + ''; + }; + + localDatabaseName = mkOption { + type = types.str; + default = "mattermost"; + description = '' + Local Mattermost database name. + ''; + }; + + localDatabaseUser = mkOption { + type = types.str; + default = "mattermost"; + description = '' + Local Mattermost database username. + ''; + }; + + localDatabasePassword = mkOption { + type = types.str; + default = "mmpgsecret"; + description = '' + Password for local Mattermost database user. + ''; + }; + + user = mkOption { + type = types.str; + default = "mattermost"; + description = '' + User which runs the Mattermost service. + ''; + }; + + group = mkOption { + type = types.str; + default = "mattermost"; + description = '' + Group which runs the Mattermost service. + ''; + }; + + matterircd = { + enable = mkEnableOption "Mattermost IRC bridge"; + package = mkOption { + type = types.package; + default = pkgs.matterircd; + defaultText = "pkgs.matterircd"; + description = "matterircd derivation to use."; + }; + parameters = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "-mmserver chat.example.com" "-bind [::]:6667" ]; + description = '' + Set commandline parameters to pass to matterircd. See + https://github.com/42wim/matterircd#usage for more information. + ''; + }; + }; + }; + }; + + config = mkMerge [ + (mkIf cfg.enable { + users.users = optionalAttrs (cfg.user == "mattermost") { + mattermost = { + group = cfg.group; + uid = config.ids.uids.mattermost; + home = cfg.statePath; + }; + }; + + users.groups = optionalAttrs (cfg.group == "mattermost") { + mattermost.gid = config.ids.gids.mattermost; + }; + + services.postgresql.enable = cfg.localDatabaseCreate; + + # The systemd service will fail to execute the preStart hook + # if the WorkingDirectory does not exist + system.activationScripts.mattermost = '' + mkdir -p "${cfg.statePath}" + ''; + + systemd.services.mattermost = { + description = "Mattermost chat service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "postgresql.service" ]; + + preStart = '' + mkdir -p "${cfg.statePath}"/{data,config,logs,plugins} + mkdir -p "${cfg.statePath}/plugins"/{client,server} + ln -sf ${cfg.package}/{bin,fonts,i18n,templates,client} "${cfg.statePath}" + '' + lib.optionalString (mattermostPlugins != null) '' + rm -rf "${cfg.statePath}/data/plugins" + ln -sf ${mattermostPlugins}/data/plugins "${cfg.statePath}/data" + '' + lib.optionalString (!cfg.mutableConfig) '' + rm -f "${cfg.statePath}/config/config.json" + ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" + '' + lib.optionalString cfg.mutableConfig '' + if ! test -e "${cfg.statePath}/config/.initial-created"; then + rm -f ${cfg.statePath}/config/config.json + ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" + touch "${cfg.statePath}/config/.initial-created" + fi + '' + lib.optionalString (cfg.mutableConfig && cfg.preferNixConfig) '' + new_config="$(${pkgs.jq}/bin/jq -s '.[0] * .[1]' "${cfg.statePath}/config/config.json" ${mattermostConfJSON})" + + rm -f "${cfg.statePath}/config/config.json" + echo "$new_config" > "${cfg.statePath}/config/config.json" + '' + lib.optionalString cfg.localDatabaseCreate (createDb {}) + '' + # Don't change permissions recursively on the data, current, and symlinked directories (see ln -sf command above). + # This dramatically decreases startup times for installations with a lot of files. + find . -maxdepth 1 -not -name data -not -name client -not -name templates -not -name i18n -not -name fonts -not -name bin -not -name . \ + -exec chown "${cfg.user}:${cfg.group}" -R {} \; -exec chmod u+rw,g+r,o-rwx -R {} \; + + chown "${cfg.user}:${cfg.group}" "${cfg.statePath}/data" . + chmod u+rw,g+r,o-rwx "${cfg.statePath}/data" . + ''; + + serviceConfig = { + PermissionsStartOnly = true; + User = cfg.user; + Group = cfg.group; + ExecStart = "${cfg.package}/bin/mattermost"; + WorkingDirectory = "${cfg.statePath}"; + Restart = "always"; + RestartSec = "10"; + LimitNOFILE = "49152"; + }; + unitConfig.JoinsNamespaceOf = mkIf cfg.localDatabaseCreate "postgresql.service"; + }; + }) + (mkIf cfg.matterircd.enable { + systemd.services.matterircd = { + description = "Mattermost IRC bridge service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = "nobody"; + Group = "nogroup"; + ExecStart = "${cfg.matterircd.package}/bin/matterircd ${escapeShellArgs cfg.matterircd.parameters}"; + WorkingDirectory = "/tmp"; + PrivateTmp = true; + Restart = "always"; + RestartSec = "5"; + }; + }; + }) + ]; +} diff --git a/nixos/modules/services/web-apps/mediawiki.nix b/nixos/modules/services/web-apps/mediawiki.nix new file mode 100644 index 00000000000..977b6f60b23 --- /dev/null +++ b/nixos/modules/services/web-apps/mediawiki.nix @@ -0,0 +1,475 @@ +{ config, pkgs, lib, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption; + inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionals optionalString types; + + cfg = config.services.mediawiki; + fpm = config.services.phpfpm.pools.mediawiki; + user = "mediawiki"; + group = config.services.httpd.group; + cacheDir = "/var/cache/mediawiki"; + stateDir = "/var/lib/mediawiki"; + + pkg = pkgs.stdenv.mkDerivation rec { + pname = "mediawiki-full"; + version = src.version; + src = cfg.package; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + rm -rf $out/share/mediawiki/skins/* + rm -rf $out/share/mediawiki/extensions/* + + ${concatStringsSep "\n" (mapAttrsToList (k: v: '' + ln -s ${v} $out/share/mediawiki/skins/${k} + '') cfg.skins)} + + ${concatStringsSep "\n" (mapAttrsToList (k: v: '' + ln -s ${if v != null then v else "$src/share/mediawiki/extensions/${k}"} $out/share/mediawiki/extensions/${k} + '') cfg.extensions)} + ''; + }; + + mediawikiScripts = pkgs.runCommand "mediawiki-scripts" { + buildInputs = [ pkgs.makeWrapper ]; + preferLocalBuild = true; + } '' + mkdir -p $out/bin + for i in changePassword.php createAndPromote.php userOptions.php edit.php nukePage.php update.php; do + makeWrapper ${pkgs.php}/bin/php $out/bin/mediawiki-$(basename $i .php) \ + --set MEDIAWIKI_CONFIG ${mediawikiConfig} \ + --add-flags ${pkg}/share/mediawiki/maintenance/$i + done + ''; + + mediawikiConfig = pkgs.writeText "LocalSettings.php" '' + <?php + # Protect against web entry + if ( !defined( 'MEDIAWIKI' ) ) { + exit; + } + + $wgSitename = "${cfg.name}"; + $wgMetaNamespace = false; + + ## The URL base path to the directory containing the wiki; + ## defaults for all runtime URL paths are based off of this. + ## For more information on customizing the URLs + ## (like /w/index.php/Page_title to /wiki/Page_title) please see: + ## https://www.mediawiki.org/wiki/Manual:Short_URL + $wgScriptPath = ""; + + ## The protocol and server name to use in fully-qualified URLs + $wgServer = "${if cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL then "https" else "http"}://${cfg.virtualHost.hostName}"; + + ## The URL path to static resources (images, scripts, etc.) + $wgResourceBasePath = $wgScriptPath; + + ## The URL path to the logo. Make sure you change this from the default, + ## or else you'll overwrite your logo when you upgrade! + $wgLogo = "$wgResourceBasePath/resources/assets/wiki.png"; + + ## UPO means: this is also a user preference option + + $wgEnableEmail = true; + $wgEnableUserEmail = true; # UPO + + $wgEmergencyContact = "${if cfg.virtualHost.adminAddr != null then cfg.virtualHost.adminAddr else config.services.httpd.adminAddr}"; + $wgPasswordSender = $wgEmergencyContact; + + $wgEnotifUserTalk = false; # UPO + $wgEnotifWatchlist = false; # UPO + $wgEmailAuthentication = true; + + ## Database settings + $wgDBtype = "${cfg.database.type}"; + $wgDBserver = "${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}"; + $wgDBname = "${cfg.database.name}"; + $wgDBuser = "${cfg.database.user}"; + ${optionalString (cfg.database.passwordFile != null) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"} + + ${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) '' + # MySQL specific settings + $wgDBprefix = "${cfg.database.tablePrefix}"; + ''} + + ${optionalString (cfg.database.type == "mysql") '' + # MySQL table options to use during installation or update + $wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary"; + ''} + + ## Shared memory settings + $wgMainCacheType = CACHE_NONE; + $wgMemCachedServers = []; + + ${optionalString (cfg.uploadsDir != null) '' + $wgEnableUploads = true; + $wgUploadDirectory = "${cfg.uploadsDir}"; + ''} + + $wgUseImageMagick = true; + $wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert"; + + # InstantCommons allows wiki to use images from https://commons.wikimedia.org + $wgUseInstantCommons = false; + + # Periodically send a pingback to https://www.mediawiki.org/ with basic data + # about this MediaWiki instance. The Wikimedia Foundation shares this data + # with MediaWiki developers to help guide future development efforts. + $wgPingback = true; + + ## If you use ImageMagick (or any other shell command) on a + ## Linux server, this will need to be set to the name of an + ## available UTF-8 locale + $wgShellLocale = "C.UTF-8"; + + ## Set $wgCacheDirectory to a writable directory on the web server + ## to make your wiki go slightly faster. The directory should not + ## be publically accessible from the web. + $wgCacheDirectory = "${cacheDir}"; + + # Site language code, should be one of the list in ./languages/data/Names.php + $wgLanguageCode = "en"; + + $wgSecretKey = file_get_contents("${stateDir}/secret.key"); + + # Changing this will log out all existing sessions. + $wgAuthenticationTokenVersion = ""; + + ## For attaching licensing metadata to pages, and displaying an + ## appropriate copyright notice / icon. GNU Free Documentation + ## License and Creative Commons licenses are supported so far. + $wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright + $wgRightsUrl = ""; + $wgRightsText = ""; + $wgRightsIcon = ""; + + # Path to the GNU diff3 utility. Used for conflict resolution. + $wgDiff = "${pkgs.diffutils}/bin/diff"; + $wgDiff3 = "${pkgs.diffutils}/bin/diff3"; + + # Enabled skins. + ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)} + + # Enabled extensions. + ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)} + + + # End of automatically generated settings. + # Add more configuration options below. + + ${cfg.extraConfig} + ''; + +in +{ + # interface + options = { + services.mediawiki = { + + enable = mkEnableOption "MediaWiki"; + + package = mkOption { + type = types.package; + default = pkgs.mediawiki; + defaultText = literalExpression "pkgs.mediawiki"; + description = "Which MediaWiki package to use."; + }; + + name = mkOption { + type = types.str; + default = "MediaWiki"; + example = "Foobar Wiki"; + description = "Name of the wiki."; + }; + + uploadsDir = mkOption { + type = types.nullOr types.path; + default = "${stateDir}/uploads"; + description = '' + This directory is used for uploads of pictures. The directory passed here is automatically + created and permissions adjusted as required. + ''; + }; + + passwordFile = mkOption { + type = types.path; + description = "A file containing the initial password for the admin user."; + example = "/run/keys/mediawiki-password"; + }; + + skins = mkOption { + default = {}; + type = types.attrsOf types.path; + description = '' + Attribute set of paths whose content is copied to the <filename>skins</filename> + subdirectory of the MediaWiki installation in addition to the default skins. + ''; + }; + + extensions = mkOption { + default = {}; + type = types.attrsOf (types.nullOr types.path); + description = '' + Attribute set of paths whose content is copied to the <filename>extensions</filename> + subdirectory of the MediaWiki installation and enabled in configuration. + + Use <literal>null</literal> instead of path to enable extensions that are part of MediaWiki. + ''; + example = literalExpression '' + { + Matomo = pkgs.fetchzip { + url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz"; + sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b"; + }; + ParserFunctions = null; + } + ''; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "postgres" "sqlite" "mssql" "oracle" ]; + default = "mysql"; + description = "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "mediawiki"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "mediawiki"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/mediawiki-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + tablePrefix = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + If you only have access to a single database and wish to install more than + one version of MediaWiki, or have other applications that also use the + database, you can give the table names a unique prefix to stop any naming + conflicts or confusion. + See <link xlink:href='https://www.mediawiki.org/wiki/Manual:$wgDBprefix'/>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = if cfg.database.createLocally then "/run/mysqld/mysqld.sock" else null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = cfg.database.type == "mysql"; + defaultText = literalExpression "true"; + description = '' + Create the database and database user locally. + This currently only applies if database type "mysql" is selected. + ''; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "mediawiki.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the MediaWiki PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + description = '' + Any additional text to be appended to MediaWiki's + LocalSettings.php configuration file. For configuration + settings, see <link xlink:href="https://www.mediawiki.org/wiki/Manual:Configuration_settings"/>. + ''; + default = ""; + example = '' + $wgEnableEmail = false; + ''; + }; + + }; + }; + + # implementation + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.type == "mysql"; + message = "services.mediawiki.createLocally is currently only supported for database type 'mysql'"; + } + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.socket != null; + message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true"; + } + ]; + + services.mediawiki.skins = { + MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook"; + Timeless = "${cfg.package}/share/mediawiki/skins/Timeless"; + Vector = "${cfg.package}/share/mediawiki/skins/Vector"; + }; + + services.mysql = mkIf cfg.database.createLocally { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.phpfpm.pools.mediawiki = { + inherit user group; + phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}"; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${pkg}/share/mediawiki"; + extraConfig = '' + <Directory "${pkg}/share/mediawiki"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + + Require all granted + DirectoryIndex index.php + AllowOverride All + </Directory> + '' + optionalString (cfg.uploadsDir != null) '' + Alias "/images" "${cfg.uploadsDir}" + <Directory "${cfg.uploadsDir}"> + Require all granted + </Directory> + ''; + } ]; + }; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + "d '${cacheDir}' 0750 ${user} ${group} - -" + ] ++ optionals (cfg.uploadsDir != null) [ + "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -" + "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -" + ]; + + systemd.services.mediawiki-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-mediawiki.service" ]; + after = optional cfg.database.createLocally "mysql.service"; + script = '' + if ! test -e "${stateDir}/secret.key"; then + tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key + fi + + echo "exit( wfGetDB( DB_MASTER )->tableExists( 'user' ) ? 1 : 0 );" | \ + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/eval.php --conf ${mediawikiConfig} && \ + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/install.php \ + --confpath /tmp \ + --scriptpath / \ + --dbserver ${cfg.database.host}${optionalString (cfg.database.socket != null) ":${cfg.database.socket}"} \ + --dbport ${toString cfg.database.port} \ + --dbname ${cfg.database.name} \ + ${optionalString (cfg.database.tablePrefix != null) "--dbprefix ${cfg.database.tablePrefix}"} \ + --dbuser ${cfg.database.user} \ + ${optionalString (cfg.database.passwordFile != null) "--dbpassfile ${cfg.database.passwordFile}"} \ + --passfile ${cfg.passwordFile} \ + ${cfg.name} \ + admin + + ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick + ''; + + serviceConfig = { + Type = "oneshot"; + User = user; + Group = group; + PrivateTmp = true; + }; + }; + + systemd.services.httpd.after = optional (cfg.database.createLocally && cfg.database.type == "mysql") "mysql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + + environment.systemPackages = [ mediawikiScripts ]; + }; +} diff --git a/nixos/modules/services/web-apps/miniflux.nix b/nixos/modules/services/web-apps/miniflux.nix new file mode 100644 index 00000000000..641c9be85d8 --- /dev/null +++ b/nixos/modules/services/web-apps/miniflux.nix @@ -0,0 +1,127 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.miniflux; + + defaultAddress = "localhost:8080"; + + dbUser = "miniflux"; + dbName = "miniflux"; + + pgbin = "${config.services.postgresql.package}/bin"; + preStart = pkgs.writeScript "miniflux-pre-start" '' + #!${pkgs.runtimeShell} + ${pgbin}/psql "${dbName}" -c "CREATE EXTENSION IF NOT EXISTS hstore" + ''; +in + +{ + options = { + services.miniflux = { + enable = mkEnableOption "miniflux and creates a local postgres database for it"; + + config = mkOption { + type = types.attrsOf types.str; + example = literalExpression '' + { + CLEANUP_FREQUENCY = "48"; + LISTEN_ADDR = "localhost:8080"; + } + ''; + description = '' + Configuration for Miniflux, refer to + <link xlink:href="https://miniflux.app/docs/configuration.html"/> + for documentation on the supported values. + + Correct configuration for the database is already provided. + By default, listens on ${defaultAddress}. + ''; + }; + + adminCredentialsFile = mkOption { + type = types.path; + description = '' + File containing the ADMIN_USERNAME and + ADMIN_PASSWORD (length >= 6) in the format of + an EnvironmentFile=, as described by systemd.exec(5). + ''; + example = "/etc/nixos/miniflux-admin-credentials"; + }; + }; + }; + + config = mkIf cfg.enable { + + services.miniflux.config = { + LISTEN_ADDR = mkDefault defaultAddress; + DATABASE_URL = "user=${dbUser} host=/run/postgresql dbname=${dbName}"; + RUN_MIGRATIONS = "1"; + CREATE_ADMIN = "1"; + }; + + services.postgresql = { + enable = true; + ensureUsers = [ { + name = dbUser; + ensurePermissions = { + "DATABASE ${dbName}" = "ALL PRIVILEGES"; + }; + } ]; + ensureDatabases = [ dbName ]; + }; + + systemd.services.miniflux-dbsetup = { + description = "Miniflux database setup"; + requires = [ "postgresql.service" ]; + after = [ "network.target" "postgresql.service" ]; + serviceConfig = { + Type = "oneshot"; + User = config.services.postgresql.superUser; + ExecStart = preStart; + }; + }; + + systemd.services.miniflux = { + description = "Miniflux service"; + wantedBy = [ "multi-user.target" ]; + requires = [ "miniflux-dbsetup.service" ]; + after = [ "network.target" "postgresql.service" "miniflux-dbsetup.service" ]; + + serviceConfig = { + ExecStart = "${pkgs.miniflux}/bin/miniflux"; + User = dbUser; + DynamicUser = true; + RuntimeDirectory = "miniflux"; + RuntimeDirectoryMode = "0700"; + EnvironmentFile = cfg.adminCredentialsFile; + # Hardening + CapabilityBoundingSet = [ "" ]; + DeviceAllow = [ "" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateDevices = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + UMask = "0077"; + }; + + environment = cfg.config; + }; + environment.systemPackages = [ pkgs.miniflux ]; + }; +} diff --git a/nixos/modules/services/web-apps/moodle.nix b/nixos/modules/services/web-apps/moodle.nix new file mode 100644 index 00000000000..19f3e754691 --- /dev/null +++ b/nixos/modules/services/web-apps/moodle.nix @@ -0,0 +1,315 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types; + inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionalString; + + cfg = config.services.moodle; + fpm = config.services.phpfpm.pools.moodle; + + user = "moodle"; + group = config.services.httpd.group; + stateDir = "/var/lib/moodle"; + + moodleConfig = pkgs.writeText "config.php" '' + <?php // Moodle configuration file + + unset($CFG); + global $CFG; + $CFG = new stdClass(); + + $CFG->dbtype = '${ { mysql = "mariadb"; pgsql = "pgsql"; }.${cfg.database.type} }'; + $CFG->dblibrary = 'native'; + $CFG->dbhost = '${cfg.database.host}'; + $CFG->dbname = '${cfg.database.name}'; + $CFG->dbuser = '${cfg.database.user}'; + ${optionalString (cfg.database.passwordFile != null) "$CFG->dbpass = file_get_contents('${cfg.database.passwordFile}');"} + $CFG->prefix = 'mdl_'; + $CFG->dboptions = array ( + 'dbpersist' => 0, + 'dbport' => '${toString cfg.database.port}', + ${optionalString (cfg.database.socket != null) "'dbsocket' => '${cfg.database.socket}',"} + 'dbcollation' => 'utf8mb4_unicode_ci', + ); + + $CFG->wwwroot = '${if cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL then "https" else "http"}://${cfg.virtualHost.hostName}'; + $CFG->dataroot = '${stateDir}'; + $CFG->admin = 'admin'; + + $CFG->directorypermissions = 02777; + $CFG->disableupdateautodeploy = true; + + $CFG->pathtogs = '${pkgs.ghostscript}/bin/gs'; + $CFG->pathtophp = '${phpExt}/bin/php'; + $CFG->pathtodu = '${pkgs.coreutils}/bin/du'; + $CFG->aspellpath = '${pkgs.aspell}/bin/aspell'; + $CFG->pathtodot = '${pkgs.graphviz}/bin/dot'; + + ${cfg.extraConfig} + + require_once('${cfg.package}/share/moodle/lib/setup.php'); + + // There is no php closing tag in this file, + // it is intentional because it prevents trailing whitespace problems! + ''; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + + phpExt = pkgs.php74.withExtensions + ({ enabled, all }: with all; [ iconv mbstring curl openssl tokenizer xmlrpc soap ctype zip gd simplexml dom intl json sqlite3 pgsql pdo_sqlite pdo_pgsql pdo_odbc pdo_mysql pdo mysqli session zlib xmlreader fileinfo filter opcache ]); +in +{ + # interface + options.services.moodle = { + enable = mkEnableOption "Moodle web application"; + + package = mkOption { + type = types.package; + default = pkgs.moodle; + defaultText = literalExpression "pkgs.moodle"; + description = "The Moodle package to use."; + }; + + initialPassword = mkOption { + type = types.str; + example = "correcthorsebatterystaple"; + description = '' + Specifies the initial password for the admin, i.e. the password assigned if the user does not already exist. + The password specified here is world-readable in the Nix store, so it should be changed promptly. + ''; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" ]; + default = "mysql"; + description = "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.int; + description = "Database host port."; + default = { + mysql = 3306; + pgsql = 5432; + }.${cfg.database.type}; + defaultText = literalExpression "3306"; + }; + + name = mkOption { + type = types.str; + default = "moodle"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "moodle"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/moodle-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = + if mysqlLocal then "/run/mysqld/mysqld.sock" + else if pgsqlLocal then "/run/postgresql" + else null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "moodle.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the Moodle PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Any additional text to be appended to the config.php + configuration file. This is a PHP script. For configuration + details, see <link xlink:href="https://docs.moodle.org/37/en/Configuration_file"/>. + ''; + example = '' + $CFG->disableupdatenotifications = true; + ''; + }; + }; + + # implementation + config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.moodle.database.user must be set to ${user} if services.moodle.database.createLocally is set true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.moodle.database.createLocally is set to true"; + } + ]; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { + "${cfg.database.name}.*" = "SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER"; + }; + } + ]; + }; + + services.postgresql = mkIf pgsqlLocal { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.phpfpm.pools.moodle = { + inherit user group; + phpPackage = phpExt; + phpEnv.MOODLE_CONFIG = "${moodleConfig}"; + phpOptions = '' + zend_extension = opcache.so + opcache.enable = 1 + ''; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${cfg.package}/share/moodle"; + extraConfig = '' + <Directory "${cfg.package}/share/moodle"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + ]; + + systemd.services.moodle-init = { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-moodle.service" ]; + after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + environment.MOODLE_CONFIG = moodleConfig; + script = '' + ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/check_database_schema.php && rc=$? || rc=$? + + [ "$rc" == 1 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/upgrade.php \ + --non-interactive \ + --allow-unstable + + [ "$rc" == 2 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/install_database.php \ + --agree-license \ + --adminpass=${cfg.initialPassword} + + true + ''; + serviceConfig = { + User = user; + Group = group; + Type = "oneshot"; + }; + }; + + systemd.services.moodle-cron = { + description = "Moodle cron service"; + after = [ "moodle-init.service" ]; + environment.MOODLE_CONFIG = moodleConfig; + serviceConfig = { + User = user; + Group = group; + ExecStart = "${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/cron.php"; + }; + }; + + systemd.timers.moodle-cron = { + description = "Moodle cron timer"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "minutely"; + }; + }; + + systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + + users.users.${user} = { + group = group; + isSystemUser = true; + }; + }; +} diff --git a/nixos/modules/services/web-apps/nextcloud.nix b/nixos/modules/services/web-apps/nextcloud.nix new file mode 100644 index 00000000000..b32220a5e57 --- /dev/null +++ b/nixos/modules/services/web-apps/nextcloud.nix @@ -0,0 +1,933 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.nextcloud; + fpm = config.services.phpfpm.pools.nextcloud; + + inherit (cfg) datadir; + + phpPackage = cfg.phpPackage.buildEnv { + extensions = { enabled, all }: + (with all; + enabled + ++ optional cfg.enableImagemagick imagick + # Optionally enabled depending on caching settings + ++ optional cfg.caching.apcu apcu + ++ optional cfg.caching.redis redis + ++ optional cfg.caching.memcached memcached + ) + ++ cfg.phpExtraExtensions all; # Enabled by user + extraConfig = toKeyValue phpOptions; + }; + + toKeyValue = generators.toKeyValue { + mkKeyValue = generators.mkKeyValueDefault {} " = "; + }; + + phpOptions = { + upload_max_filesize = cfg.maxUploadSize; + post_max_size = cfg.maxUploadSize; + memory_limit = cfg.maxUploadSize; + } // cfg.phpOptions + // optionalAttrs cfg.caching.apcu { + "apc.enable_cli" = "1"; + }; + + occ = pkgs.writeScriptBin "nextcloud-occ" '' + #! ${pkgs.runtimeShell} + cd ${cfg.package} + sudo=exec + if [[ "$USER" != nextcloud ]]; then + sudo='exec /run/wrappers/bin/sudo -u nextcloud --preserve-env=NEXTCLOUD_CONFIG_DIR --preserve-env=OC_PASS' + fi + export NEXTCLOUD_CONFIG_DIR="${datadir}/config" + $sudo \ + ${phpPackage}/bin/php \ + occ "$@" + ''; + + inherit (config.system) stateVersion; + +in { + + imports = [ + (mkRemovedOptionModule [ "services" "nextcloud" "config" "adminpass" ] '' + Please use `services.nextcloud.config.adminpassFile' instead! + '') + (mkRemovedOptionModule [ "services" "nextcloud" "config" "dbpass" ] '' + Please use `services.nextcloud.config.dbpassFile' instead! + '') + (mkRemovedOptionModule [ "services" "nextcloud" "nginx" "enable" ] '' + The nextcloud module supports `nginx` as reverse-proxy by default and doesn't + support other reverse-proxies officially. + + However it's possible to use an alternative reverse-proxy by + + * disabling nginx + * setting `listen.owner` & `listen.group` in the phpfpm-pool to a different value + + Further details about this can be found in the `Nextcloud`-section of the NixOS-manual + (which can be openend e.g. by running `nixos-help`). + '') + (mkRemovedOptionModule [ "services" "nextcloud" "disableImagemagick" ] '' + Use services.nextcloud.nginx.enableImagemagick instead. + '') + ]; + + options.services.nextcloud = { + enable = mkEnableOption "nextcloud"; + hostName = mkOption { + type = types.str; + description = "FQDN for the nextcloud instance."; + }; + home = mkOption { + type = types.str; + default = "/var/lib/nextcloud"; + description = "Storage path of nextcloud."; + }; + datadir = mkOption { + type = types.str; + defaultText = "config.services.nextcloud.home"; + description = '' + Data storage path of nextcloud. Will be <xref linkend="opt-services.nextcloud.home" /> by default. + This folder will be populated with a config.php and data folder which contains the state of the instance (excl the database)."; + ''; + example = "/mnt/nextcloud-file"; + }; + extraApps = mkOption { + type = types.attrsOf types.package; + default = { }; + description = '' + Extra apps to install. Should be an attrSet of appid to packages generated by fetchNextcloudApp. + The appid must be identical to the "id" value in the apps appinfo/info.xml. + Using this will disable the appstore to prevent Nextcloud from updating these apps (see <xref linkend="opt-services.nextcloud.appstoreEnable" />). + ''; + example = literalExpression '' + { + maps = pkgs.fetchNextcloudApp { + name = "maps"; + sha256 = "007y80idqg6b6zk6kjxg4vgw0z8fsxs9lajnv49vv1zjy6jx2i1i"; + url = "https://github.com/nextcloud/maps/releases/download/v0.1.9/maps-0.1.9.tar.gz"; + version = "0.1.9"; + }; + phonetrack = pkgs.fetchNextcloudApp { + name = "phonetrack"; + sha256 = "0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc"; + url = "https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz"; + version = "0.6.9"; + }; + } + ''; + }; + extraAppsEnable = mkOption { + type = types.bool; + default = true; + description = '' + Automatically enable the apps in <xref linkend="opt-services.nextcloud.extraApps" /> every time nextcloud starts. + If set to false, apps need to be enabled in the Nextcloud user interface or with nextcloud-occ app:enable. + ''; + }; + appstoreEnable = mkOption { + type = types.nullOr types.bool; + default = null; + example = true; + description = '' + Allow the installation of apps and app updates from the store. + Enabled by default unless there are packages in <xref linkend="opt-services.nextcloud.extraApps" />. + Set to true to force enable the store even if <xref linkend="opt-services.nextcloud.extraApps" /> is used. + Set to false to disable the installation of apps from the global appstore. App management is always enabled regardless of this setting. + ''; + }; + logLevel = mkOption { + type = types.ints.between 0 4; + default = 2; + description = "Log level value between 0 (DEBUG) and 4 (FATAL)."; + }; + https = mkOption { + type = types.bool; + default = false; + description = "Use https for generated links."; + }; + package = mkOption { + type = types.package; + description = "Which package to use for the Nextcloud instance."; + relatedPackages = [ "nextcloud22" "nextcloud23" ]; + }; + phpPackage = mkOption { + type = types.package; + relatedPackages = [ "php74" "php80" ]; + defaultText = "pkgs.php"; + description = '' + PHP package to use for Nextcloud. + ''; + }; + + maxUploadSize = mkOption { + default = "512M"; + type = types.str; + description = '' + Defines the upload limit for files. This changes the relevant options + in php.ini and nginx if enabled. + ''; + }; + + skeletonDirectory = mkOption { + default = ""; + type = types.str; + description = '' + The directory where the skeleton files are located. These files will be + copied to the data directory of new users. Leave empty to not copy any + skeleton files. + ''; + }; + + webfinger = mkOption { + type = types.bool; + default = false; + description = '' + Enable this option if you plan on using the webfinger plugin. + The appropriate nginx rewrite rules will be added to your configuration. + ''; + }; + + phpExtraExtensions = mkOption { + type = with types; functionTo (listOf package); + default = all: []; + defaultText = literalExpression "all: []"; + description = '' + Additional PHP extensions to use for nextcloud. + By default, only extensions necessary for a vanilla nextcloud installation are enabled, + but you may choose from the list of available extensions and add further ones. + This is sometimes necessary to be able to install a certain nextcloud app that has additional requirements. + ''; + example = literalExpression '' + all: [ all.pdlib all.bz2 ] + ''; + }; + + phpOptions = mkOption { + type = types.attrsOf types.str; + default = { + short_open_tag = "Off"; + expose_php = "Off"; + error_reporting = "E_ALL & ~E_DEPRECATED & ~E_STRICT"; + display_errors = "stderr"; + "opcache.enable_cli" = "1"; + "opcache.interned_strings_buffer" = "8"; + "opcache.max_accelerated_files" = "10000"; + "opcache.memory_consumption" = "128"; + "opcache.revalidate_freq" = "1"; + "opcache.fast_shutdown" = "1"; + "openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt"; + catch_workers_output = "yes"; + }; + description = '' + Options for PHP's php.ini file for nextcloud. + ''; + }; + + poolSettings = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = "32"; + "pm.start_servers" = "2"; + "pm.min_spare_servers" = "2"; + "pm.max_spare_servers" = "4"; + "pm.max_requests" = "500"; + }; + description = '' + Options for nextcloud's PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives. + ''; + }; + + poolConfig = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Options for nextcloud's PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives. + ''; + }; + + config = { + dbtype = mkOption { + type = types.enum [ "sqlite" "pgsql" "mysql" ]; + default = "sqlite"; + description = "Database type."; + }; + dbname = mkOption { + type = types.nullOr types.str; + default = "nextcloud"; + description = "Database name."; + }; + dbuser = mkOption { + type = types.nullOr types.str; + default = "nextcloud"; + description = "Database user."; + }; + dbpassFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The full path to a file that contains the database password. + ''; + }; + dbhost = mkOption { + type = types.nullOr types.str; + default = "localhost"; + description = '' + Database host. + + Note: for using Unix authentication with PostgreSQL, this should be + set to <literal>/run/postgresql</literal>. + ''; + }; + dbport = mkOption { + type = with types; nullOr (either int str); + default = null; + description = "Database port."; + }; + dbtableprefix = mkOption { + type = types.nullOr types.str; + default = null; + description = "Table prefix in Nextcloud database."; + }; + adminuser = mkOption { + type = types.str; + default = "root"; + description = "Admin username."; + }; + adminpassFile = mkOption { + type = types.str; + description = '' + The full path to a file that contains the admin's password. Must be + readable by user <literal>nextcloud</literal>. + ''; + }; + + extraTrustedDomains = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Trusted domains, from which the nextcloud installation will be + acessible. You don't need to add + <literal>services.nextcloud.hostname</literal> here. + ''; + }; + + trustedProxies = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Trusted proxies, to provide if the nextcloud installation is being + proxied to secure against e.g. spoofing. + ''; + }; + + overwriteProtocol = mkOption { + type = types.nullOr (types.enum [ "http" "https" ]); + default = null; + example = "https"; + + description = '' + Force Nextcloud to always use HTTPS i.e. for link generation. Nextcloud + uses the currently used protocol by default, but when behind a reverse-proxy, + it may use <literal>http</literal> for everything although Nextcloud + may be served via HTTPS. + ''; + }; + + defaultPhoneRegion = mkOption { + default = null; + type = types.nullOr types.str; + example = "DE"; + description = '' + <warning> + <para>This option exists since Nextcloud 21! If older versions are used, + this will throw an eval-error!</para> + </warning> + + <link xlink:href="https://www.iso.org/iso-3166-country-codes.html">ISO 3611-1</link> + country codes for automatic phone-number detection without a country code. + + With e.g. <literal>DE</literal> set, the <literal>+49</literal> can be omitted for + phone-numbers. + ''; + }; + + objectstore = { + s3 = { + enable = mkEnableOption '' + S3 object storage as primary storage. + + This mounts a bucket on an Amazon S3 object storage or compatible + implementation into the virtual filesystem. + + Further details about this feature can be found in the + <link xlink:href="https://docs.nextcloud.com/server/22/admin_manual/configuration_files/primary_storage.html">upstream documentation</link>. + ''; + bucket = mkOption { + type = types.str; + example = "nextcloud"; + description = '' + The name of the S3 bucket. + ''; + }; + autocreate = mkOption { + type = types.bool; + description = '' + Create the objectstore if it does not exist. + ''; + }; + key = mkOption { + type = types.str; + example = "EJ39ITYZEUH5BGWDRUFY"; + description = '' + The access key for the S3 bucket. + ''; + }; + secretFile = mkOption { + type = types.str; + example = "/var/nextcloud-objectstore-s3-secret"; + description = '' + The full path to a file that contains the access secret. Must be + readable by user <literal>nextcloud</literal>. + ''; + }; + hostname = mkOption { + type = types.nullOr types.str; + default = null; + example = "example.com"; + description = '' + Required for some non-Amazon implementations. + ''; + }; + port = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + Required for some non-Amazon implementations. + ''; + }; + useSsl = mkOption { + type = types.bool; + default = true; + description = '' + Use SSL for objectstore access. + ''; + }; + region = mkOption { + type = types.nullOr types.str; + default = null; + example = "REGION"; + description = '' + Required for some non-Amazon implementations. + ''; + }; + usePathStyle = mkOption { + type = types.bool; + default = false; + description = '' + Required for some non-Amazon S3 implementations. + + Ordinarily, requests will be made with + <literal>http://bucket.hostname.domain/</literal>, but with path style + enabled requests are made with + <literal>http://hostname.domain/bucket</literal> instead. + ''; + }; + }; + }; + }; + + enableImagemagick = mkEnableOption '' + the ImageMagick module for PHP. + This is used by the theming app and for generating previews of certain images (e.g. SVG and HEIF). + You may want to disable it for increased security. In that case, previews will still be available + for some images (e.g. JPEG and PNG). + See <link xlink:href="https://github.com/nextcloud/server/issues/13099" />. + '' // { + default = true; + }; + + caching = { + apcu = mkOption { + type = types.bool; + default = true; + description = '' + Whether to load the APCu module into PHP. + ''; + }; + redis = mkOption { + type = types.bool; + default = false; + description = '' + Whether to load the Redis module into PHP. + You still need to enable Redis in your config.php. + See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html + ''; + }; + memcached = mkOption { + type = types.bool; + default = false; + description = '' + Whether to load the Memcached module into PHP. + You still need to enable Memcached in your config.php. + See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html + ''; + }; + }; + autoUpdateApps = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Run regular auto update of all apps installed from the nextcloud app store. + ''; + }; + startAt = mkOption { + type = with types; either str (listOf str); + default = "05:00:00"; + example = "Sun 14:00:00"; + description = '' + When to run the update. See `systemd.services.<name>.startAt`. + ''; + }; + }; + occ = mkOption { + type = types.package; + default = occ; + defaultText = literalDocBook "generated script"; + internal = true; + description = '' + The nextcloud-occ program preconfigured to target this Nextcloud instance. + ''; + }; + + nginx.recommendedHttpHeaders = mkOption { + type = types.bool; + default = true; + description = "Enable additional recommended HTTP response headers"; + }; + }; + + config = mkIf cfg.enable (mkMerge [ + { warnings = let + latest = 23; + upgradeWarning = major: nixos: + '' + A legacy Nextcloud install (from before NixOS ${nixos}) may be installed. + + After nextcloud${toString major} is installed successfully, you can safely upgrade + to ${toString (major + 1)}. The latest version available is nextcloud${toString latest}. + + Please note that Nextcloud doesn't support upgrades across multiple major versions + (i.e. an upgrade from 16 is possible to 17, but not 16 to 18). + + The package can be upgraded by explicitly declaring the service-option + `services.nextcloud.package`. + ''; + + # FIXME(@Ma27) remove as soon as nextcloud properly supports + # mariadb >=10.6. + isUnsupportedMariadb = + # All currently supported Nextcloud versions are affected (https://github.com/nextcloud/server/issues/25436). + (versionOlder cfg.package.version "24") + # This module uses mysql + && (cfg.config.dbtype == "mysql") + # MySQL is managed via NixOS + && config.services.mysql.enable + # We're using MariaDB + && (getName config.services.mysql.package) == "mariadb-server" + # MariaDB is at least 10.6 and thus not supported + && (versionAtLeast (getVersion config.services.mysql.package) "10.6"); + + in (optional (cfg.poolConfig != null) '' + Using config.services.nextcloud.poolConfig is deprecated and will become unsupported in a future release. + Please migrate your configuration to config.services.nextcloud.poolSettings. + '') + ++ (optional (versionOlder cfg.package.version "21") (upgradeWarning 20 "21.05")) + ++ (optional (versionOlder cfg.package.version "22") (upgradeWarning 21 "21.11")) + ++ (optional (versionOlder cfg.package.version "23") (upgradeWarning 22 "22.05")) + ++ (optional isUnsupportedMariadb '' + You seem to be using MariaDB at an unsupported version (i.e. at least 10.6)! + Please note that this isn't supported officially by Nextcloud. You can either + + * Switch to `pkgs.mysql` + * Downgrade MariaDB to at least 10.5 + * Work around Nextcloud's problems by specifying `innodb_read_only_compressed=0` + + For further context, please read + https://help.nextcloud.com/t/update-to-next-cloud-21-0-2-has-get-an-error/117028/15 + ''); + + services.nextcloud.package = with pkgs; + mkDefault ( + if pkgs ? nextcloud + then throw '' + The `pkgs.nextcloud`-attribute has been removed. If it's supposed to be the default + nextcloud defined in an overlay, please set `services.nextcloud.package` to + `pkgs.nextcloud`. + '' + else if versionOlder stateVersion "21.11" then nextcloud21 + else if versionOlder stateVersion "22.05" then nextcloud22 + else nextcloud23 + ); + + services.nextcloud.datadir = mkOptionDefault config.services.nextcloud.home; + + services.nextcloud.phpPackage = + if versionOlder cfg.package.version "21" then pkgs.php74 + else pkgs.php80; + } + + { systemd.timers.nextcloud-cron = { + wantedBy = [ "timers.target" ]; + timerConfig.OnBootSec = "5m"; + timerConfig.OnUnitActiveSec = "5m"; + timerConfig.Unit = "nextcloud-cron.service"; + }; + + systemd.tmpfiles.rules = ["d ${cfg.home} 0750 nextcloud nextcloud"]; + + systemd.services = { + # When upgrading the Nextcloud package, Nextcloud can report errors such as + # "The files of the app [all apps in /var/lib/nextcloud/apps] were not replaced correctly" + # Restarting phpfpm on Nextcloud package update fixes these issues (but this is a workaround). + phpfpm-nextcloud.restartTriggers = [ cfg.package ]; + + nextcloud-setup = let + c = cfg.config; + writePhpArrary = a: "[${concatMapStringsSep "," (val: ''"${toString val}"'') a}]"; + requiresReadSecretFunction = c.dbpassFile != null || c.objectstore.s3.enable; + objectstoreConfig = let s3 = c.objectstore.s3; in optionalString s3.enable '' + 'objectstore' => [ + 'class' => '\\OC\\Files\\ObjectStore\\S3', + 'arguments' => [ + 'bucket' => '${s3.bucket}', + 'autocreate' => ${boolToString s3.autocreate}, + 'key' => '${s3.key}', + 'secret' => nix_read_secret('${s3.secretFile}'), + ${optionalString (s3.hostname != null) "'hostname' => '${s3.hostname}',"} + ${optionalString (s3.port != null) "'port' => ${toString s3.port},"} + 'use_ssl' => ${boolToString s3.useSsl}, + ${optionalString (s3.region != null) "'region' => '${s3.region}',"} + 'use_path_style' => ${boolToString s3.usePathStyle}, + ], + ] + ''; + + showAppStoreSetting = cfg.appstoreEnable != null || cfg.extraApps != {}; + renderedAppStoreSetting = + let + x = cfg.appstoreEnable; + in + if x == null then "false" + else boolToString x; + + overrideConfig = pkgs.writeText "nextcloud-config.php" '' + <?php + ${optionalString requiresReadSecretFunction '' + function nix_read_secret($file) { + if (!file_exists($file)) { + throw new \RuntimeException(sprintf( + "Cannot start Nextcloud, secret file %s set by NixOS doesn't seem to " + . "exist! Please make sure that the file exists and has appropriate " + . "permissions for user & group 'nextcloud'!", + $file + )); + } + + return trim(file_get_contents($file)); + } + ''} + $CONFIG = [ + 'apps_paths' => [ + ${optionalString (cfg.extraApps != { }) "[ 'path' => '${cfg.home}/nix-apps', 'url' => '/nix-apps', 'writable' => false ],"} + [ 'path' => '${cfg.home}/apps', 'url' => '/apps', 'writable' => false ], + [ 'path' => '${cfg.home}/store-apps', 'url' => '/store-apps', 'writable' => true ], + ], + ${optionalString (showAppStoreSetting) "'appstoreenabled' => ${renderedAppStoreSetting},"} + 'datadirectory' => '${datadir}/data', + 'skeletondirectory' => '${cfg.skeletonDirectory}', + ${optionalString cfg.caching.apcu "'memcache.local' => '\\OC\\Memcache\\APCu',"} + 'log_type' => 'syslog', + 'log_level' => '${builtins.toString cfg.logLevel}', + ${optionalString (c.overwriteProtocol != null) "'overwriteprotocol' => '${c.overwriteProtocol}',"} + ${optionalString (c.dbname != null) "'dbname' => '${c.dbname}',"} + ${optionalString (c.dbhost != null) "'dbhost' => '${c.dbhost}',"} + ${optionalString (c.dbport != null) "'dbport' => '${toString c.dbport}',"} + ${optionalString (c.dbuser != null) "'dbuser' => '${c.dbuser}',"} + ${optionalString (c.dbtableprefix != null) "'dbtableprefix' => '${toString c.dbtableprefix}',"} + ${optionalString (c.dbpassFile != null) "'dbpassword' => nix_read_secret('${c.dbpassFile}'),"} + 'dbtype' => '${c.dbtype}', + 'trusted_domains' => ${writePhpArrary ([ cfg.hostName ] ++ c.extraTrustedDomains)}, + 'trusted_proxies' => ${writePhpArrary (c.trustedProxies)}, + ${optionalString (c.defaultPhoneRegion != null) "'default_phone_region' => '${c.defaultPhoneRegion}',"} + ${objectstoreConfig} + ]; + ''; + occInstallCmd = let + mkExport = { arg, value }: "export ${arg}=${value}"; + dbpass = { + arg = "DBPASS"; + value = if c.dbpassFile != null + then ''"$(<"${toString c.dbpassFile}")"'' + else ''""''; + }; + adminpass = { + arg = "ADMINPASS"; + value = ''"$(<"${toString c.adminpassFile}")"''; + }; + installFlags = concatStringsSep " \\\n " + (mapAttrsToList (k: v: "${k} ${toString v}") { + "--database" = ''"${c.dbtype}"''; + # The following attributes are optional depending on the type of + # database. Those that evaluate to null on the left hand side + # will be omitted. + ${if c.dbname != null then "--database-name" else null} = ''"${c.dbname}"''; + ${if c.dbhost != null then "--database-host" else null} = ''"${c.dbhost}"''; + ${if c.dbport != null then "--database-port" else null} = ''"${toString c.dbport}"''; + ${if c.dbuser != null then "--database-user" else null} = ''"${c.dbuser}"''; + "--database-pass" = "\$${dbpass.arg}"; + "--admin-user" = ''"${c.adminuser}"''; + "--admin-pass" = "\$${adminpass.arg}"; + "--data-dir" = ''"${datadir}/data"''; + }); + in '' + ${mkExport dbpass} + ${mkExport adminpass} + ${occ}/bin/nextcloud-occ maintenance:install \ + ${installFlags} + ''; + occSetTrustedDomainsCmd = concatStringsSep "\n" (imap0 + (i: v: '' + ${occ}/bin/nextcloud-occ config:system:set trusted_domains \ + ${toString i} --value="${toString v}" + '') ([ cfg.hostName ] ++ cfg.config.extraTrustedDomains)); + + in { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-nextcloud.service" ]; + path = [ occ ]; + script = '' + ${optionalString (c.dbpassFile != null) '' + if [ ! -r "${c.dbpassFile}" ]; then + echo "dbpassFile ${c.dbpassFile} is not readable by nextcloud:nextcloud! Aborting..." + exit 1 + fi + if [ -z "$(<${c.dbpassFile})" ]; then + echo "dbpassFile ${c.dbpassFile} is empty!" + exit 1 + fi + ''} + if [ ! -r "${c.adminpassFile}" ]; then + echo "adminpassFile ${c.adminpassFile} is not readable by nextcloud:nextcloud! Aborting..." + exit 1 + fi + if [ -z "$(<${c.adminpassFile})" ]; then + echo "adminpassFile ${c.adminpassFile} is empty!" + exit 1 + fi + + ln -sf ${cfg.package}/apps ${cfg.home}/ + + # Install extra apps + ln -sfT \ + ${pkgs.linkFarm "nix-apps" + (mapAttrsToList (name: path: { inherit name path; }) cfg.extraApps)} \ + ${cfg.home}/nix-apps + + # create nextcloud directories. + # if the directories exist already with wrong permissions, we fix that + for dir in ${datadir}/config ${datadir}/data ${cfg.home}/store-apps ${cfg.home}/nix-apps; do + if [ ! -e $dir ]; then + install -o nextcloud -g nextcloud -d $dir + elif [ $(stat -c "%G" $dir) != "nextcloud" ]; then + chgrp -R nextcloud $dir + fi + done + + ln -sf ${overrideConfig} ${datadir}/config/override.config.php + + # Do not install if already installed + if [[ ! -e ${datadir}/config/config.php ]]; then + ${occInstallCmd} + fi + + ${occ}/bin/nextcloud-occ upgrade + + ${occ}/bin/nextcloud-occ config:system:delete trusted_domains + + ${optionalString (cfg.extraAppsEnable && cfg.extraApps != { }) '' + # Try to enable apps (don't fail when one of them cannot be enabled , eg. due to incompatible version) + ${occ}/bin/nextcloud-occ app:enable ${concatStringsSep " " (attrNames cfg.extraApps)} + ''} + + ${occSetTrustedDomainsCmd} + ''; + serviceConfig.Type = "oneshot"; + serviceConfig.User = "nextcloud"; + }; + nextcloud-cron = { + environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config"; + serviceConfig.Type = "oneshot"; + serviceConfig.User = "nextcloud"; + serviceConfig.ExecStart = "${phpPackage}/bin/php -f ${cfg.package}/cron.php"; + }; + nextcloud-update-plugins = mkIf cfg.autoUpdateApps.enable { + serviceConfig.Type = "oneshot"; + serviceConfig.ExecStart = "${occ}/bin/nextcloud-occ app:update --all"; + serviceConfig.User = "nextcloud"; + startAt = cfg.autoUpdateApps.startAt; + }; + }; + + services.phpfpm = { + pools.nextcloud = { + user = "nextcloud"; + group = "nextcloud"; + phpPackage = phpPackage; + phpEnv = { + NEXTCLOUD_CONFIG_DIR = "${datadir}/config"; + PATH = "/run/wrappers/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin"; + }; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = config.services.nginx.user; + "listen.group" = config.services.nginx.group; + } // cfg.poolSettings; + extraConfig = cfg.poolConfig; + }; + }; + + users.users.nextcloud = { + home = "${cfg.home}"; + group = "nextcloud"; + isSystemUser = true; + }; + users.groups.nextcloud.members = [ "nextcloud" config.services.nginx.user ]; + + environment.systemPackages = [ occ ]; + + services.nginx.enable = mkDefault true; + + services.nginx.virtualHosts.${cfg.hostName} = { + root = cfg.package; + locations = { + "= /robots.txt" = { + priority = 100; + extraConfig = '' + allow all; + log_not_found off; + access_log off; + ''; + }; + "= /" = { + priority = 100; + extraConfig = '' + if ( $http_user_agent ~ ^DavClnt ) { + return 302 /remote.php/webdav/$is_args$args; + } + ''; + }; + "/" = { + priority = 900; + extraConfig = "rewrite ^ /index.php;"; + }; + "~ ^/store-apps" = { + priority = 201; + extraConfig = "root ${cfg.home};"; + }; + "~ ^/nix-apps" = { + priority = 201; + extraConfig = "root ${cfg.home};"; + }; + "^~ /.well-known" = { + priority = 210; + extraConfig = '' + absolute_redirect off; + location = /.well-known/carddav { + return 301 /remote.php/dav; + } + location = /.well-known/caldav { + return 301 /remote.php/dav; + } + location ~ ^/\.well-known/(?!acme-challenge|pki-validation) { + return 301 /index.php$request_uri; + } + try_files $uri $uri/ =404; + ''; + }; + "~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/)".extraConfig = '' + return 404; + ''; + "~ ^/(?:\\.(?!well-known)|autotest|occ|issue|indie|db_|console)".extraConfig = '' + return 404; + ''; + "~ ^\\/(?:index|remote|public|cron|core\\/ajax\\/update|status|ocs\\/v[12]|updater\\/.+|oc[ms]-provider\\/.+|.+\\/richdocumentscode\\/proxy)\\.php(?:$|\\/)" = { + priority = 500; + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi.conf; + fastcgi_split_path_info ^(.+?\.php)(\\/.*)$; + set $path_info $fastcgi_path_info; + try_files $fastcgi_script_name =404; + fastcgi_param PATH_INFO $path_info; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTPS ${if cfg.https then "on" else "off"}; + fastcgi_param modHeadersAvailable true; + fastcgi_param front_controller_active true; + fastcgi_pass unix:${fpm.socket}; + fastcgi_intercept_errors on; + fastcgi_request_buffering off; + fastcgi_read_timeout 120s; + ''; + }; + "~ \\.(?:css|js|woff2?|svg|gif|map)$".extraConfig = '' + try_files $uri /index.php$request_uri; + expires 6M; + access_log off; + ''; + "~ ^\\/(?:updater|ocs-provider|ocm-provider)(?:$|\\/)".extraConfig = '' + try_files $uri/ =404; + index index.php; + ''; + "~ \\.(?:png|html|ttf|ico|jpg|jpeg|bcmap|mp4|webm)$".extraConfig = '' + try_files $uri /index.php$request_uri; + access_log off; + ''; + }; + extraConfig = '' + index index.php index.html /index.php$request_uri; + ${optionalString (cfg.nginx.recommendedHttpHeaders) '' + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Robots-Tag none; + add_header X-Download-Options noopen; + add_header X-Permitted-Cross-Domain-Policies none; + add_header X-Frame-Options sameorigin; + add_header Referrer-Policy no-referrer; + add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; + ''} + client_max_body_size ${cfg.maxUploadSize}; + fastcgi_buffers 64 4K; + fastcgi_hide_header X-Powered-By; + gzip on; + gzip_vary on; + gzip_comp_level 4; + gzip_min_length 256; + gzip_proxied expired no-cache no-store private no_last_modified no_etag auth; + gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy; + + ${optionalString cfg.webfinger '' + rewrite ^/.well-known/host-meta /public.php?service=host-meta last; + rewrite ^/.well-known/host-meta.json /public.php?service=host-meta-json last; + ''} + ''; + }; + } + ]); + + meta.doc = ./nextcloud.xml; +} diff --git a/nixos/modules/services/web-apps/nextcloud.xml b/nixos/modules/services/web-apps/nextcloud.xml new file mode 100644 index 00000000000..8f55086a2bd --- /dev/null +++ b/nixos/modules/services/web-apps/nextcloud.xml @@ -0,0 +1,291 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-nextcloud"> + <title>Nextcloud</title> + <para> + <link xlink:href="https://nextcloud.com/">Nextcloud</link> is an open-source, + self-hostable cloud platform. The server setup can be automated using + <link linkend="opt-services.nextcloud.enable">services.nextcloud</link>. A + desktop client is packaged at <literal>pkgs.nextcloud-client</literal>. + </para> + <para> + The current default by NixOS is <package>nextcloud23</package> which is also the latest + major version available. + </para> + <section xml:id="module-services-nextcloud-basic-usage"> + <title>Basic usage</title> + + <para> + Nextcloud is a PHP-based application which requires an HTTP server + (<literal><link linkend="opt-services.nextcloud.enable">services.nextcloud</link></literal> + optionally supports + <literal><link linkend="opt-services.nginx.enable">services.nginx</link></literal>) + and a database (it's recommended to use + <literal><link linkend="opt-services.postgresql.enable">services.postgresql</link></literal>). + </para> + + <para> + A very basic configuration may look like this: +<programlisting>{ pkgs, ... }: +{ + services.nextcloud = { + <link linkend="opt-services.nextcloud.enable">enable</link> = true; + <link linkend="opt-services.nextcloud.hostName">hostName</link> = "nextcloud.tld"; + config = { + <link linkend="opt-services.nextcloud.config.dbtype">dbtype</link> = "pgsql"; + <link linkend="opt-services.nextcloud.config.dbuser">dbuser</link> = "nextcloud"; + <link linkend="opt-services.nextcloud.config.dbhost">dbhost</link> = "/run/postgresql"; # nextcloud will add /.s.PGSQL.5432 by itself + <link linkend="opt-services.nextcloud.config.dbname">dbname</link> = "nextcloud"; + <link linkend="opt-services.nextcloud.config.adminpassFile">adminpassFile</link> = "/path/to/admin-pass-file"; + <link linkend="opt-services.nextcloud.config.adminuser">adminuser</link> = "root"; + }; + }; + + services.postgresql = { + <link linkend="opt-services.postgresql.enable">enable</link> = true; + <link linkend="opt-services.postgresql.ensureDatabases">ensureDatabases</link> = [ "nextcloud" ]; + <link linkend="opt-services.postgresql.ensureUsers">ensureUsers</link> = [ + { name = "nextcloud"; + ensurePermissions."DATABASE nextcloud" = "ALL PRIVILEGES"; + } + ]; + }; + + # ensure that postgres is running *before* running the setup + systemd.services."nextcloud-setup" = { + requires = ["postgresql.service"]; + after = ["postgresql.service"]; + }; + + <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ]; +}</programlisting> + </para> + + <para> + The <literal>hostName</literal> option is used internally to configure an HTTP + server using <literal><link xlink:href="https://php-fpm.org/">PHP-FPM</link></literal> + and <literal>nginx</literal>. The <literal>config</literal> attribute set is + used by the imperative installer and all values are written to an additional file + to ensure that changes can be applied by changing the module's options. + </para> + + <para> + In case the application serves multiple domains (those are checked with + <literal><link xlink:href="http://php.net/manual/en/reserved.variables.server.php">$_SERVER['HTTP_HOST']</link></literal>) + it's needed to add them to + <literal><link linkend="opt-services.nextcloud.config.extraTrustedDomains">services.nextcloud.config.extraTrustedDomains</link></literal>. + </para> + + <para> + Auto updates for Nextcloud apps can be enabled using + <literal><link linkend="opt-services.nextcloud.autoUpdateApps.enable">services.nextcloud.autoUpdateApps</link></literal>. +</para> + + </section> + + <section xml:id="module-services-nextcloud-pitfalls-during-upgrade"> + <title>Common problems</title> + <itemizedlist> + <listitem> + <formalpara> + <title>General notes</title> + <para> + Unfortunately Nextcloud appears to be very stateful when it comes to + managing its own configuration. The config file lives in the home directory + of the <literal>nextcloud</literal> user (by default + <literal>/var/lib/nextcloud/config/config.php</literal>) and is also used to + track several states of the application (e.g., whether installed or not). + </para> + </formalpara> + <para> + All configuration parameters are also stored in + <filename>/var/lib/nextcloud/config/override.config.php</filename> which is generated by + the module and linked from the store to ensure that all values from + <filename>config.php</filename> can be modified by the module. + However <filename>config.php</filename> manages the application's state and shouldn't be + touched manually because of that. + </para> + <warning> + <para>Don't delete <filename>config.php</filename>! This file + tracks the application's state and a deletion can cause unwanted + side-effects!</para> + </warning> + + <warning> + <para>Don't rerun <literal>nextcloud-occ + maintenance:install</literal>! This command tries to install the application + and can cause unwanted side-effects!</para> + </warning> + </listitem> + <listitem> + <formalpara> + <title>Multiple version upgrades</title> + <para> + Nextcloud doesn't allow to move more than one major-version forward. E.g., if you're on + <literal>v16</literal>, you cannot upgrade to <literal>v18</literal>, you need to upgrade to + <literal>v17</literal> first. This is ensured automatically as long as the + <link linkend="opt-system.stateVersion">stateVersion</link> is declared properly. In that case + the oldest version available (one major behind the one from the previous NixOS + release) will be selected by default and the module will generate a warning that reminds + the user to upgrade to latest Nextcloud <emphasis>after</emphasis> that deploy. + </para> + </formalpara> + </listitem> + <listitem> + <formalpara> + <title><literal>Error: Command "upgrade" is not defined.</literal></title> + <para> + This error usually occurs if the initial installation + (<command>nextcloud-occ maintenance:install</command>) has failed. After that, the application + is not installed, but the upgrade is attempted to be executed. Further context can + be found in <link xlink:href="https://github.com/NixOS/nixpkgs/issues/111175">NixOS/nixpkgs#111175</link>. + </para> + </formalpara> + <para> + First of all, it makes sense to find out what went wrong by looking at the logs + of the installation via <command>journalctl -u nextcloud-setup</command> and try to fix + the underlying issue. + </para> + <itemizedlist> + <listitem> + <para> + If this occurs on an <emphasis>existing</emphasis> setup, this is most likely because + the maintenance mode is active. It can be deactivated by running + <command>nextcloud-occ maintenance:mode --off</command>. It's advisable though to + check the logs first on why the maintenance mode was activated. + </para> + </listitem> + <listitem> + <warning><para>Only perform the following measures on + <emphasis>freshly installed instances!</emphasis></para></warning> + <para> + A re-run of the installer can be forced by <emphasis>deleting</emphasis> + <filename>/var/lib/nextcloud/config/config.php</filename>. This is the only time + advisable because the fresh install doesn't have any state that can be lost. + In case that doesn't help, an entire re-creation can be forced via + <command>rm -rf ~nextcloud/</command>. + </para> + </listitem> + </itemizedlist> + </listitem> + </itemizedlist> + </section> + + <section xml:id="module-services-nextcloud-httpd"> + <title>Using an alternative webserver as reverse-proxy (e.g. <literal>httpd</literal>)</title> + <para> + By default, <package>nginx</package> is used as reverse-proxy for <package>nextcloud</package>. + However, it's possible to use e.g. <package>httpd</package> by explicitly disabling + <package>nginx</package> using <xref linkend="opt-services.nginx.enable" /> and fixing the + settings <literal>listen.owner</literal> & <literal>listen.group</literal> in the + <link linkend="opt-services.phpfpm.pools">corresponding <literal>phpfpm</literal> pool</link>. + </para> + <para> + An exemplary configuration may look like this: +<programlisting>{ config, lib, pkgs, ... }: { + <link linkend="opt-services.nginx.enable">services.nginx.enable</link> = false; + services.nextcloud = { + <link linkend="opt-services.nextcloud.enable">enable</link> = true; + <link linkend="opt-services.nextcloud.hostName">hostName</link> = "localhost"; + + /* further, required options */ + }; + <link linkend="opt-services.phpfpm.pools._name_.settings">services.phpfpm.pools.nextcloud.settings</link> = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + }; + services.httpd = { + <link linkend="opt-services.httpd.enable">enable</link> = true; + <link linkend="opt-services.httpd.adminAddr">adminAddr</link> = "webmaster@localhost"; + <link linkend="opt-services.httpd.extraModules">extraModules</link> = [ "proxy_fcgi" ]; + virtualHosts."localhost" = { + <link linkend="opt-services.httpd.virtualHosts._name_.documentRoot">documentRoot</link> = config.services.nextcloud.package; + <link linkend="opt-services.httpd.virtualHosts._name_.extraConfig">extraConfig</link> = '' + <Directory "${config.services.nextcloud.package}"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${config.services.phpfpm.pools.nextcloud.socket}|fcgi://localhost/" + </If> + </FilesMatch> + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteBase / + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.php [L] + </IfModule> + DirectoryIndex index.php + Require all granted + Options +FollowSymLinks + </Directory> + ''; + }; + }; +}</programlisting> + </para> + </section> + + <section xml:id="installing-apps-php-extensions-nextcloud"> + <title>Installing Apps and PHP extensions</title> + + <para> + Nextcloud apps are installed statefully through the web interface. + + Some apps may require extra PHP extensions to be installed. + This can be configured with the <xref linkend="opt-services.nextcloud.phpExtraExtensions" /> setting. + </para> + + <para> + Alternatively, extra apps can also be declared with the <xref linkend="opt-services.nextcloud.extraApps" /> setting. + When using this setting, apps can no longer be managed statefully because this can lead to Nextcloud updating apps + that are managed by Nix. If you want automatic updates it is recommended that you use web interface to install apps. + </para> + </section> + + <section xml:id="module-services-nextcloud-maintainer-info"> + <title>Maintainer information</title> + + <para> + As stated in the previous paragraph, we must provide a clean upgrade-path for Nextcloud + since it cannot move more than one major version forward on a single upgrade. This chapter + adds some notes how Nextcloud updates should be rolled out in the future. + </para> + + <para> + While minor and patch-level updates are no problem and can be done directly in the + package-expression (and should be backported to supported stable branches after that), + major-releases should be added in a new attribute (e.g. Nextcloud <literal>v19.0.0</literal> + should be available in <literal>nixpkgs</literal> as <literal>pkgs.nextcloud19</literal>). + To provide simple upgrade paths it's generally useful to backport those as well to stable + branches. As long as the package-default isn't altered, this won't break existing setups. + After that, the versioning-warning in the <literal>nextcloud</literal>-module should be + updated to make sure that the + <link linkend="opt-services.nextcloud.package">package</link>-option selects the latest version + on fresh setups. + </para> + + <para> + If major-releases will be abandoned by upstream, we should check first if those are needed + in NixOS for a safe upgrade-path before removing those. In that case we shold keep those + packages, but mark them as insecure in an expression like this (in + <literal><nixpkgs/pkgs/servers/nextcloud/default.nix></literal>): +<programlisting>/* ... */ +{ + nextcloud17 = generic { + version = "17.0.x"; + sha256 = "0000000000000000000000000000000000000000000000000000"; + eol = true; + }; +}</programlisting> + </para> + + <para> + Ideally we should make sure that it's possible to jump two NixOS versions forward: + i.e. the warnings and the logic in the module should guard a user to upgrade from a + Nextcloud on e.g. 19.09 to a Nextcloud on 20.09. + </para> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/nexus.nix b/nixos/modules/services/web-apps/nexus.nix new file mode 100644 index 00000000000..dc50a06705f --- /dev/null +++ b/nixos/modules/services/web-apps/nexus.nix @@ -0,0 +1,156 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.nexus; + +in + +{ + options = { + services.nexus = { + enable = mkEnableOption "Sonatype Nexus3 OSS service"; + + package = mkOption { + type = types.package; + default = pkgs.nexus; + defaultText = literalExpression "pkgs.nexus"; + description = "Package which runs Nexus3"; + }; + + user = mkOption { + type = types.str; + default = "nexus"; + description = "User which runs Nexus3."; + }; + + group = mkOption { + type = types.str; + default = "nexus"; + description = "Group which runs Nexus3."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/sonatype-work"; + description = "Home directory of the Nexus3 instance."; + }; + + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Address to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8081; + description = "Port to listen on."; + }; + + jvmOpts = mkOption { + type = types.lines; + default = '' + -Xms1200M + -Xmx1200M + -XX:MaxDirectMemorySize=2G + -XX:+UnlockDiagnosticVMOptions + -XX:+UnsyncloadClass + -XX:+LogVMOutput + -XX:LogFile=${cfg.home}/nexus3/log/jvm.log + -XX:-OmitStackTraceInFastThrow + -Djava.net.preferIPv4Stack=true + -Dkaraf.home=${cfg.package} + -Dkaraf.base=${cfg.package} + -Dkaraf.etc=${cfg.package}/etc/karaf + -Djava.util.logging.config.file=${cfg.package}/etc/karaf/java.util.logging.properties + -Dkaraf.data=${cfg.home}/nexus3 + -Djava.io.tmpdir=${cfg.home}/nexus3/tmp + -Dkaraf.startLocalConsole=false + -Djava.endorsed.dirs=${cfg.package}/lib/endorsed + ''; + defaultText = literalExpression '' + ''' + -Xms1200M + -Xmx1200M + -XX:MaxDirectMemorySize=2G + -XX:+UnlockDiagnosticVMOptions + -XX:+UnsyncloadClass + -XX:+LogVMOutput + -XX:LogFile=''${home}/nexus3/log/jvm.log + -XX:-OmitStackTraceInFastThrow + -Djava.net.preferIPv4Stack=true + -Dkaraf.home=''${package} + -Dkaraf.base=''${package} + -Dkaraf.etc=''${package}/etc/karaf + -Djava.util.logging.config.file=''${package}/etc/karaf/java.util.logging.properties + -Dkaraf.data=''${home}/nexus3 + -Djava.io.tmpdir=''${home}/nexus3/tmp + -Dkaraf.startLocalConsole=false + -Djava.endorsed.dirs=''${package}/lib/endorsed + ''' + ''; + + description = '' + Options for the JVM written to `nexus.jvmopts`. + Please refer to the docs (https://help.sonatype.com/repomanager3/installation/configuring-the-runtime-environment) + for further information. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + createHome = true; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.nexus = { + description = "Sonatype Nexus3"; + + wantedBy = [ "multi-user.target" ]; + + path = [ cfg.home ]; + + environment = { + NEXUS_USER = cfg.user; + NEXUS_HOME = cfg.home; + + VM_OPTS_FILE = pkgs.writeText "nexus.vmoptions" cfg.jvmOpts; + }; + + preStart = '' + mkdir -p ${cfg.home}/nexus3/etc + + if [ ! -f ${cfg.home}/nexus3/etc/nexus.properties ]; then + echo "# Jetty section" > ${cfg.home}/nexus3/etc/nexus.properties + echo "application-port=${toString cfg.listenPort}" >> ${cfg.home}/nexus3/etc/nexus.properties + echo "application-host=${toString cfg.listenAddress}" >> ${cfg.home}/nexus3/etc/nexus.properties + else + sed 's/^application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^# application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties + sed 's/^# application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties + fi + ''; + + script = "${cfg.package}/bin/nexus run"; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + LimitNOFILE = 102642; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ ironpinguin ]; +} diff --git a/nixos/modules/services/web-apps/node-red.nix b/nixos/modules/services/web-apps/node-red.nix new file mode 100644 index 00000000000..4512907f027 --- /dev/null +++ b/nixos/modules/services/web-apps/node-red.nix @@ -0,0 +1,149 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.node-red; + defaultUser = "node-red"; + finalPackage = if cfg.withNpmAndGcc then node-red_withNpmAndGcc else cfg.package; + node-red_withNpmAndGcc = pkgs.runCommand "node-red" { + nativeBuildInputs = [ pkgs.makeWrapper ]; + } + '' + mkdir -p $out/bin + makeWrapper ${pkgs.nodePackages.node-red}/bin/node-red $out/bin/node-red \ + --set PATH '${lib.makeBinPath [ pkgs.nodePackages.npm pkgs.gcc ]}:$PATH' \ + ''; +in +{ + options.services.node-red = { + enable = mkEnableOption "the Node-RED service"; + + package = mkOption { + default = pkgs.nodePackages.node-red; + defaultText = literalExpression "pkgs.nodePackages.node-red"; + type = types.package; + description = "Node-RED package to use."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for the server. + ''; + }; + + withNpmAndGcc = mkOption { + type = types.bool; + default = false; + description = '' + Give Node-RED access to NPM and GCC at runtime, so 'Nodes' can be + downloaded and managed imperatively via the 'Palette Manager'. + ''; + }; + + configFile = mkOption { + type = types.path; + default = "${cfg.package}/lib/node_modules/node-red/settings.js"; + defaultText = literalExpression ''"''${package}/lib/node_modules/node-red/settings.js"''; + description = '' + Path to the JavaScript configuration file. + See <link + xlink:href="https://github.com/node-red/node-red/blob/master/packages/node_modules/node-red/settings.js"/> + for a configuration example. + ''; + }; + + port = mkOption { + type = types.port; + default = 1880; + description = "Listening port."; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = '' + User under which Node-RED runs.If left as the default value this user + will automatically be created on system activation, otherwise the + sysadmin is responsible for ensuring the user exists. + ''; + }; + + group = mkOption { + type = types.str; + default = defaultUser; + description = '' + Group under which Node-RED runs.If left as the default value this group + will automatically be created on system activation, otherwise the + sysadmin is responsible for ensuring the group exists. + ''; + }; + + userDir = mkOption { + type = types.path; + default = "/var/lib/node-red"; + description = '' + The directory to store all user data, such as flow and credential files and all library data. If left + as the default value this directory will automatically be created before the node-red service starts, + otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership + and permissions. + ''; + }; + + safe = mkOption { + type = types.bool; + default = false; + description = "Whether to launch Node-RED in --safe mode."; + }; + + define = mkOption { + type = types.attrs; + default = {}; + description = "List of settings.js overrides to pass via -D to Node-RED."; + example = literalExpression '' + { + "logging.console.level" = "trace"; + } + ''; + }; + }; + + config = mkIf cfg.enable { + users.users = optionalAttrs (cfg.user == defaultUser) { + ${defaultUser} = { + isSystemUser = true; + group = defaultUser; + }; + }; + + users.groups = optionalAttrs (cfg.group == defaultUser) { + ${defaultUser} = { }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + + systemd.services.node-red = { + description = "Node-RED Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + environment = { + HOME = cfg.userDir; + }; + serviceConfig = mkMerge [ + { + User = cfg.user; + Group = cfg.group; + ExecStart = "${finalPackage}/bin/node-red ${pkgs.lib.optionalString cfg.safe "--safe"} --settings ${cfg.configFile} --port ${toString cfg.port} --userDir ${cfg.userDir} ${concatStringsSep " " (mapAttrsToList (name: value: "-D ${name}=${value}") cfg.define)}"; + PrivateTmp = true; + Restart = "always"; + WorkingDirectory = cfg.userDir; + } + (mkIf (cfg.userDir == "/var/lib/node-red") { StateDirectory = "node-red"; }) + ]; + }; + }; +} diff --git a/nixos/modules/services/web-apps/openwebrx.nix b/nixos/modules/services/web-apps/openwebrx.nix new file mode 100644 index 00000000000..9e90c01e0bb --- /dev/null +++ b/nixos/modules/services/web-apps/openwebrx.nix @@ -0,0 +1,34 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.openwebrx; +in +{ + options.services.openwebrx = with lib; { + enable = mkEnableOption "OpenWebRX Web interface for Software-Defined Radios on http://localhost:8073"; + + package = mkOption { + type = types.package; + default = pkgs.openwebrx; + defaultText = literalExpression "pkgs.openwebrx"; + description = "OpenWebRX package to use for the service"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.openwebrx = { + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ + csdr + alsaUtils + netcat + ]; + serviceConfig = { + ExecStart = "${cfg.package}/bin/openwebrx"; + Restart = "always"; + DynamicUser = true; + # openwebrx uses /var/lib/openwebrx by default + StateDirectory = [ "openwebrx" ]; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/peertube.nix b/nixos/modules/services/web-apps/peertube.nix new file mode 100644 index 00000000000..e195e6e6e82 --- /dev/null +++ b/nixos/modules/services/web-apps/peertube.nix @@ -0,0 +1,475 @@ +{ lib, pkgs, config, options, ... }: + +let + cfg = config.services.peertube; + opt = options.services.peertube; + + settingsFormat = pkgs.formats.json {}; + configFile = settingsFormat.generate "production.json" cfg.settings; + + env = { + NODE_CONFIG_DIR = "/var/lib/peertube/config"; + NODE_ENV = "production"; + NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/ca-certificates.crt"; + NPM_CONFIG_PREFIX = cfg.package; + HOME = cfg.package; + }; + + systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@memlock" "@mount" "@obsolete" "@privileged" "@setuid" ]; + + cfgService = { + # Proc filesystem + ProcSubset = "pid"; + ProtectProc = "invisible"; + # Access write directories + UMask = "0027"; + # Capabilities + CapabilityBoundingSet = ""; + # Security + NoNewPrivileges = true; + # Sandboxing + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + # System Call Filtering + SystemCallArchitectures = "native"; + }; + + envFile = pkgs.writeText "peertube.env" (lib.concatMapStrings (s: s + "\n") ( + (lib.concatLists (lib.mapAttrsToList (name: value: + if value != null then [ + "${name}=\"${toString value}\"" + ] else [] + ) env)))); + + peertubeEnv = pkgs.writeShellScriptBin "peertube-env" '' + set -a + source "${envFile}" + eval -- "\$@" + ''; + + peertubeCli = pkgs.writeShellScriptBin "peertube" '' + node ~/dist/server/tools/peertube.js $@ + ''; + +in { + options.services.peertube = { + enable = lib.mkEnableOption "Enable Peertube’s service"; + + user = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = "User account under which Peertube runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = "Group under which Peertube runs."; + }; + + localDomain = lib.mkOption { + type = lib.types.str; + example = "peertube.example.com"; + description = "The domain serving your PeerTube instance."; + }; + + listenHttp = lib.mkOption { + type = lib.types.int; + default = 9000; + description = "listen port for HTTP server."; + }; + + listenWeb = lib.mkOption { + type = lib.types.int; + default = 9000; + description = "listen port for WEB server."; + }; + + enableWebHttps = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable or disable HTTPS protocol."; + }; + + dataDirs = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + example = [ "/opt/peertube/storage" "/var/cache/peertube" ]; + description = "Allow access to custom data locations."; + }; + + serviceEnvironmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-init-root"; + description = '' + Set environment variables for the service. Mainly useful for setting the initial root password. + For example write to file: + PT_INITIAL_ROOT_PASSWORD=changeme + ''; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + example = lib.literalExpression '' + { + listen = { + hostname = "0.0.0.0"; + }; + log = { + level = "debug"; + }; + storage = { + tmp = "/opt/data/peertube/storage/tmp/"; + logs = "/opt/data/peertube/storage/logs/"; + cache = "/opt/data/peertube/storage/cache/"; + }; + } + ''; + description = "Configuration for peertube."; + }; + + database = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Configure local PostgreSQL database server for PeerTube."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = if cfg.database.createLocally then "/run/postgresql" else null; + defaultText = lib.literalExpression '' + if config.${opt.database.createLocally} + then "/run/postgresql" + else null + ''; + example = "192.168.15.47"; + description = "Database host address or unix socket."; + }; + + port = lib.mkOption { + type = lib.types.int; + default = 5432; + description = "Database host port."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = "Database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "peertube"; + description = "Database user."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-posgressql-db"; + description = "Password for PostgreSQL database."; + }; + }; + + redis = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Configure local Redis server for PeerTube."; + }; + + host = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then "127.0.0.1" else null; + defaultText = lib.literalExpression '' + if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} + then "127.0.0.1" + else null + ''; + description = "Redis host."; + }; + + port = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = if cfg.redis.createLocally && cfg.redis.enableUnixSocket then null else 6379; + defaultText = lib.literalExpression '' + if config.${opt.redis.createLocally} && config.${opt.redis.enableUnixSocket} + then null + else 6379 + ''; + description = "Redis port."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-redis-db"; + description = "Password for redis database."; + }; + + enableUnixSocket = lib.mkOption { + type = lib.types.bool; + default = cfg.redis.createLocally; + defaultText = lib.literalExpression "config.${opt.redis.createLocally}"; + description = "Use Unix socket."; + }; + }; + + smtp = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Configure local Postfix SMTP server for PeerTube."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/peertube/password-smtp"; + description = "Password for smtp server."; + }; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.peertube; + defaultText = lib.literalExpression "pkgs.peertube"; + description = "Peertube package to use."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { assertion = cfg.serviceEnvironmentFile == null || !lib.hasPrefix builtins.storeDir cfg.serviceEnvironmentFile; + message = '' + <option>services.peertube.serviceEnvironmentFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = !(cfg.redis.enableUnixSocket && (cfg.redis.host != null || cfg.redis.port != null)); + message = '' + <option>services.peertube.redis.createLocally</option> and redis network connection (<option>services.peertube.redis.host</option> or <option>services.peertube.redis.port</option>) enabled. Disable either of them. + ''; + } + { assertion = cfg.redis.enableUnixSocket || (cfg.redis.host != null && cfg.redis.port != null); + message = '' + <option>services.peertube.redis.host</option> and <option>services.peertube.redis.port</option> needs to be set if <option>services.peertube.redis.enableUnixSocket</option> is not enabled. + ''; + } + { assertion = cfg.redis.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.redis.passwordFile; + message = '' + <option>services.peertube.redis.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = cfg.database.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.database.passwordFile; + message = '' + <option>services.peertube.database.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + { assertion = cfg.smtp.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.smtp.passwordFile; + message = '' + <option>services.peertube.smtp.passwordFile</option> points to + a file in the Nix store. You should use a quoted absolute path to + prevent this. + ''; + } + ]; + + services.peertube.settings = lib.mkMerge [ + { + listen = { + port = cfg.listenHttp; + }; + webserver = { + https = (if cfg.enableWebHttps then true else false); + hostname = "${cfg.localDomain}"; + port = cfg.listenWeb; + }; + database = { + hostname = "${cfg.database.host}"; + port = cfg.database.port; + name = "${cfg.database.name}"; + username = "${cfg.database.user}"; + }; + redis = { + hostname = "${toString cfg.redis.host}"; + port = (if cfg.redis.port == null then "" else cfg.redis.port); + }; + storage = { + tmp = lib.mkDefault "/var/lib/peertube/storage/tmp/"; + bin = lib.mkDefault "/var/lib/peertube/storage/bin/"; + avatars = lib.mkDefault "/var/lib/peertube/storage/avatars/"; + videos = lib.mkDefault "/var/lib/peertube/storage/videos/"; + streaming_playlists = lib.mkDefault "/var/lib/peertube/storage/streaming-playlists/"; + redundancy = lib.mkDefault "/var/lib/peertube/storage/redundancy/"; + logs = lib.mkDefault "/var/lib/peertube/storage/logs/"; + previews = lib.mkDefault "/var/lib/peertube/storage/previews/"; + thumbnails = lib.mkDefault "/var/lib/peertube/storage/thumbnails/"; + torrents = lib.mkDefault "/var/lib/peertube/storage/torrents/"; + captions = lib.mkDefault "/var/lib/peertube/storage/captions/"; + cache = lib.mkDefault "/var/lib/peertube/storage/cache/"; + plugins = lib.mkDefault "/var/lib/peertube/storage/plugins/"; + client_overrides = lib.mkDefault "/var/lib/peertube/storage/client-overrides/"; + }; + import = { + videos = { + http = { + youtube_dl_release = { + python_path = "${pkgs.python3}/bin/python"; + }; + }; + }; + }; + } + (lib.mkIf cfg.redis.enableUnixSocket { redis = { socket = "/run/redis/redis.sock"; }; }) + ]; + + systemd.tmpfiles.rules = [ + "d '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -" + "z '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.peertube-init-db = lib.mkIf cfg.database.createLocally { + description = "Initialization database for PeerTube daemon"; + after = [ "network.target" "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + + script = let + psqlSetupCommands = pkgs.writeText "peertube-init.sql" '' + SELECT 'CREATE USER "${cfg.database.user}"' WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${cfg.database.user}')\gexec + SELECT 'CREATE DATABASE "${cfg.database.name}" OWNER "${cfg.database.user}" TEMPLATE template0 ENCODING UTF8' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${cfg.database.name}')\gexec + \c '${cfg.database.name}' + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE EXTENSION IF NOT EXISTS unaccent; + ''; + in "${config.services.postgresql.package}/bin/psql -f ${psqlSetupCommands}"; + + serviceConfig = { + Type = "oneshot"; + WorkingDirectory = cfg.package; + # User and group + User = "postgres"; + Group = "postgres"; + # Sandboxing + RestrictAddressFamilies = [ "AF_UNIX" ]; + MemoryDenyWriteExecute = true; + # System Call Filtering + SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]); + } // cfgService; + }; + + systemd.services.peertube = { + description = "PeerTube daemon"; + after = [ "network.target" ] + ++ lib.optionals cfg.redis.createLocally [ "redis.service" ] + ++ lib.optionals cfg.database.createLocally [ "postgresql.service" "peertube-init-db.service" ]; + wantedBy = [ "multi-user.target" ]; + + environment = env; + + path = with pkgs; [ bashInteractive ffmpeg nodejs-16_x openssl yarn python3 ]; + + script = '' + #!/bin/sh + umask 077 + cat > /var/lib/peertube/config/local.yaml <<EOF + ${lib.optionalString ((!cfg.database.createLocally) && (cfg.database.passwordFile != null)) '' + database: + password: '$(cat ${cfg.database.passwordFile})' + ''} + ${lib.optionalString (cfg.redis.passwordFile != null) '' + redis: + auth: '$(cat ${cfg.redis.passwordFile})' + ''} + ${lib.optionalString (cfg.smtp.passwordFile != null) '' + smtp: + password: '$(cat ${cfg.smtp.passwordFile})' + ''} + EOF + ln -sf ${cfg.package}/config/default.yaml /var/lib/peertube/config/default.yaml + ln -sf ${configFile} /var/lib/peertube/config/production.json + npm start + ''; + serviceConfig = { + Type = "simple"; + Restart = "always"; + RestartSec = 20; + TimeoutSec = 60; + WorkingDirectory = cfg.package; + # User and group + User = cfg.user; + Group = cfg.group; + # State directory and mode + StateDirectory = "peertube"; + StateDirectoryMode = "0750"; + # Access write directories + ReadWritePaths = cfg.dataDirs; + # Environment + EnvironmentFile = cfg.serviceEnvironmentFile; + # Sandboxing + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ]; + MemoryDenyWriteExecute = false; + # System Call Filtering + SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "pipe" "pipe2" ]; + } // cfgService; + }; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + }; + + services.redis = lib.mkMerge [ + (lib.mkIf cfg.redis.createLocally { + enable = true; + }) + (lib.mkIf (cfg.redis.createLocally && cfg.redis.enableUnixSocket) { + unixSocket = "/run/redis/redis.sock"; + unixSocketPerm = 770; + }) + ]; + + services.postfix = lib.mkIf cfg.smtp.createLocally { + enable = true; + hostname = lib.mkDefault "${cfg.localDomain}"; + }; + + users.users = lib.mkMerge [ + (lib.mkIf (cfg.user == "peertube") { + peertube = { + isSystemUser = true; + group = cfg.group; + home = cfg.package; + }; + }) + (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package peertubeEnv peertubeCli pkgs.ffmpeg pkgs.nodejs-16_x pkgs.yarn ]) + (lib.mkIf cfg.redis.enableUnixSocket {${config.services.peertube.user}.extraGroups = [ "redis" ];}) + ]; + + users.groups = lib.optionalAttrs (cfg.group == "peertube") { + peertube = { }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/pgpkeyserver-lite.nix b/nixos/modules/services/web-apps/pgpkeyserver-lite.nix new file mode 100644 index 00000000000..faf0ce13238 --- /dev/null +++ b/nixos/modules/services/web-apps/pgpkeyserver-lite.nix @@ -0,0 +1,78 @@ +{ config, lib, options, pkgs, ... }: + +with lib; + +let + + cfg = config.services.pgpkeyserver-lite; + sksCfg = config.services.sks; + sksOpt = options.services.sks; + + webPkg = cfg.package; + +in + +{ + + options = { + + services.pgpkeyserver-lite = { + + enable = mkEnableOption "pgpkeyserver-lite on a nginx vHost proxying to a gpg keyserver"; + + package = mkOption { + default = pkgs.pgpkeyserver-lite; + defaultText = literalExpression "pkgs.pgpkeyserver-lite"; + type = types.package; + description = " + Which webgui derivation to use. + "; + }; + + hostname = mkOption { + type = types.str; + description = " + Which hostname to set the vHost to that is proxying to sks. + "; + }; + + hkpAddress = mkOption { + default = builtins.head sksCfg.hkpAddress; + defaultText = literalExpression "head config.${sksOpt.hkpAddress}"; + type = types.str; + description = " + Wich ip address the sks-keyserver is listening on. + "; + }; + + hkpPort = mkOption { + default = sksCfg.hkpPort; + defaultText = literalExpression "config.${sksOpt.hkpPort}"; + type = types.int; + description = " + Which port the sks-keyserver is listening on. + "; + }; + }; + }; + + config = mkIf cfg.enable { + + services.nginx.enable = true; + + services.nginx.virtualHosts = let + hkpPort = builtins.toString cfg.hkpPort; + in { + ${cfg.hostname} = { + root = webPkg; + locations = { + "/pks".extraConfig = '' + proxy_pass http://${cfg.hkpAddress}:${hkpPort}; + proxy_pass_header Server; + add_header Via "1.1 ${cfg.hostname}"; + ''; + }; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/pict-rs.md b/nixos/modules/services/web-apps/pict-rs.md new file mode 100644 index 00000000000..4b622049909 --- /dev/null +++ b/nixos/modules/services/web-apps/pict-rs.md @@ -0,0 +1,88 @@ +# Pict-rs {#module-services-pict-rs} + +pict-rs is a a simple image hosting service. + +## Quickstart {#module-services-pict-rs-quickstart} + +the minimum to start pict-rs is + +```nix +services.pict-rs.enable = true; +``` + +this will start the http server on port 8080 by default. + +## Usage {#module-services-pict-rs-usage} + +pict-rs offers the following endpoints: +- `POST /image` for uploading an image. Uploaded content must be valid multipart/form-data with an + image array located within the `images[]` key + + This endpoint returns the following JSON structure on success with a 201 Created status + ```json + { + "files": [ + { + "delete_token": "JFvFhqJA98", + "file": "lkWZDRvugm.jpg" + }, + { + "delete_token": "kAYy9nk2WK", + "file": "8qFS0QooAn.jpg" + }, + { + "delete_token": "OxRpM3sf0Y", + "file": "1hJaYfGE01.jpg" + } + ], + "msg": "ok" + } + ``` +- `GET /image/download?url=...` Download an image from a remote server, returning the same JSON + payload as the `POST` endpoint +- `GET /image/original/{file}` for getting a full-resolution image. `file` here is the `file` key from the + `/image` endpoint's JSON +- `GET /image/details/original/{file}` for getting the details of a full-resolution image. + The returned JSON is structured like so: + ```json + { + "width": 800, + "height": 537, + "content_type": "image/webp", + "created_at": [ + 2020, + 345, + 67376, + 394363487 + ] + } + ``` +- `GET /image/process.{ext}?src={file}&...` get a file with transformations applied. + existing transformations include + - `identity=true`: apply no changes + - `blur={float}`: apply a gaussian blur to the file + - `thumbnail={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}` + square using raw pixel sampling + - `resize={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}` square + using a Lanczos2 filter. This is slower than sampling but looks a bit better in some cases + - `crop={int-w}x{int-h}`: produce a cropped version of the image with an `{int-w}` by `{int-h}` + aspect ratio. The resulting crop will be centered on the image. Either the width or height + of the image will remain full-size, depending on the image's aspect ratio and the requested + aspect ratio. For example, a 1600x900 image cropped with a 1x1 aspect ratio will become 900x900. A + 1600x1100 image cropped with a 16x9 aspect ratio will become 1600x900. + + Supported `ext` file extensions include `png`, `jpg`, and `webp` + + An example of usage could be + ``` + GET /image/process.jpg?src=asdf.png&thumbnail=256&blur=3.0 + ``` + which would create a 256x256px JPEG thumbnail and blur it +- `GET /image/details/process.{ext}?src={file}&...` for getting the details of a processed image. + The returned JSON is the same format as listed for the full-resolution details endpoint. +- `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to + delete a file, where `delete_token` and `file` are from the `/image` endpoint's JSON + +## Missing {#module-services-pict-rs-missing} + +- Configuring the secure-api-key is not included yet. The envisioned basic use case is consumption on localhost by other services without exposing the service to the internet. diff --git a/nixos/modules/services/web-apps/pict-rs.nix b/nixos/modules/services/web-apps/pict-rs.nix new file mode 100644 index 00000000000..e1847fbd531 --- /dev/null +++ b/nixos/modules/services/web-apps/pict-rs.nix @@ -0,0 +1,50 @@ +{ lib, pkgs, config, ... }: +with lib; +let + cfg = config.services.pict-rs; +in +{ + meta.maintainers = with maintainers; [ happysalada ]; + # Don't edit the docbook xml directly, edit the md and generate it: + # `pandoc pict-rs.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > pict-rs.xml` + meta.doc = ./pict-rs.xml; + + options.services.pict-rs = { + enable = mkEnableOption "pict-rs server"; + dataDir = mkOption { + type = types.path; + default = "/var/lib/pict-rs"; + description = '' + The directory where to store the uploaded images. + ''; + }; + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = '' + The IPv4 address to deploy the service to. + ''; + }; + port = mkOption { + type = types.port; + default = 8080; + description = '' + The port which to bind the service to. + ''; + }; + }; + config = lib.mkIf cfg.enable { + systemd.services.pict-rs = { + environment = { + PICTRS_PATH = cfg.dataDir; + PICTRS_ADDR = "${cfg.address}:${toString cfg.port}"; + }; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + DynamicUser = true; + StateDirectory = "pict-rs"; + ExecStart = "${pkgs.pict-rs}/bin/pict-rs"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/pict-rs.xml b/nixos/modules/services/web-apps/pict-rs.xml new file mode 100644 index 00000000000..bf129f5cc2a --- /dev/null +++ b/nixos/modules/services/web-apps/pict-rs.xml @@ -0,0 +1,162 @@ +<chapter xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" xml:id="module-services-pict-rs"> + <title>Pict-rs</title> + <para> + pict-rs is a a simple image hosting service. + </para> + <section xml:id="module-services-pict-rs-quickstart"> + <title>Quickstart</title> + <para> + the minimum to start pict-rs is + </para> + <programlisting language="bash"> +services.pict-rs.enable = true; +</programlisting> + <para> + this will start the http server on port 8080 by default. + </para> + </section> + <section xml:id="module-services-pict-rs-usage"> + <title>Usage</title> + <para> + pict-rs offers the following endpoints: - + <literal>POST /image</literal> for uploading an image. Uploaded + content must be valid multipart/form-data with an image array + located within the <literal>images[]</literal> key + </para> + <programlisting> +This endpoint returns the following JSON structure on success with a 201 Created status +```json +{ + "files": [ + { + "delete_token": "JFvFhqJA98", + "file": "lkWZDRvugm.jpg" + }, + { + "delete_token": "kAYy9nk2WK", + "file": "8qFS0QooAn.jpg" + }, + { + "delete_token": "OxRpM3sf0Y", + "file": "1hJaYfGE01.jpg" + } + ], + "msg": "ok" +} +``` +</programlisting> + <itemizedlist> + <listitem> + <para> + <literal>GET /image/download?url=...</literal> Download an + image from a remote server, returning the same JSON payload as + the <literal>POST</literal> endpoint + </para> + </listitem> + <listitem> + <para> + <literal>GET /image/original/{file}</literal> for getting a + full-resolution image. <literal>file</literal> here is the + <literal>file</literal> key from the <literal>/image</literal> + endpoint’s JSON + </para> + </listitem> + <listitem> + <para> + <literal>GET /image/details/original/{file}</literal> for + getting the details of a full-resolution image. The returned + JSON is structured like so: + <literal>json { "width": 800, "height": 537, "content_type": "image/webp", "created_at": [ 2020, 345, 67376, 394363487 ] }</literal> + </para> + </listitem> + <listitem> + <para> + <literal>GET /image/process.{ext}?src={file}&...</literal> + get a file with transformations applied. existing + transformations include + </para> + <itemizedlist spacing="compact"> + <listitem> + <para> + <literal>identity=true</literal>: apply no changes + </para> + </listitem> + <listitem> + <para> + <literal>blur={float}</literal>: apply a gaussian blur to + the file + </para> + </listitem> + <listitem> + <para> + <literal>thumbnail={int}</literal>: produce a thumbnail of + the image fitting inside an <literal>{int}</literal> by + <literal>{int}</literal> square using raw pixel sampling + </para> + </listitem> + <listitem> + <para> + <literal>resize={int}</literal>: produce a thumbnail of + the image fitting inside an <literal>{int}</literal> by + <literal>{int}</literal> square using a Lanczos2 filter. + This is slower than sampling but looks a bit better in + some cases + </para> + </listitem> + <listitem> + <para> + <literal>crop={int-w}x{int-h}</literal>: produce a cropped + version of the image with an <literal>{int-w}</literal> by + <literal>{int-h}</literal> aspect ratio. The resulting + crop will be centered on the image. Either the width or + height of the image will remain full-size, depending on + the image’s aspect ratio and the requested aspect ratio. + For example, a 1600x900 image cropped with a 1x1 aspect + ratio will become 900x900. A 1600x1100 image cropped with + a 16x9 aspect ratio will become 1600x900. + </para> + </listitem> + </itemizedlist> + <para> + Supported <literal>ext</literal> file extensions include + <literal>png</literal>, <literal>jpg</literal>, and + <literal>webp</literal> + </para> + <para> + An example of usage could be + <literal>GET /image/process.jpg?src=asdf.png&thumbnail=256&blur=3.0</literal> + which would create a 256x256px JPEG thumbnail and blur it + </para> + </listitem> + <listitem> + <para> + <literal>GET /image/details/process.{ext}?src={file}&...</literal> + for getting the details of a processed image. The returned + JSON is the same format as listed for the full-resolution + details endpoint. + </para> + </listitem> + <listitem> + <para> + <literal>DELETE /image/delete/{delete_token}/{file}</literal> + or <literal>GET /image/delete/{delete_token}/{file}</literal> + to delete a file, where <literal>delete_token</literal> and + <literal>file</literal> are from the <literal>/image</literal> + endpoint’s JSON + </para> + </listitem> + </itemizedlist> + </section> + <section xml:id="module-services-pict-rs-missing"> + <title>Missing</title> + <itemizedlist spacing="compact"> + <listitem> + <para> + Configuring the secure-api-key is not included yet. The + envisioned basic use case is consumption on localhost by other + services without exposing the service to the internet. + </para> + </listitem> + </itemizedlist> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/plantuml-server.nix b/nixos/modules/services/web-apps/plantuml-server.nix new file mode 100644 index 00000000000..9ea37b8a4ca --- /dev/null +++ b/nixos/modules/services/web-apps/plantuml-server.nix @@ -0,0 +1,140 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.plantuml-server; + +in + +{ + options = { + services.plantuml-server = { + enable = mkEnableOption "PlantUML server"; + + package = mkOption { + type = types.package; + default = pkgs.plantuml-server; + defaultText = literalExpression "pkgs.plantuml-server"; + description = "PlantUML server package to use"; + }; + + packages = { + jdk = mkOption { + type = types.package; + default = pkgs.jdk; + defaultText = literalExpression "pkgs.jdk"; + description = "JDK package to use for the server"; + }; + jetty = mkOption { + type = types.package; + default = pkgs.jetty; + defaultText = literalExpression "pkgs.jetty"; + description = "Jetty package to use for the server"; + }; + }; + + user = mkOption { + type = types.str; + default = "plantuml"; + description = "User which runs PlantUML server."; + }; + + group = mkOption { + type = types.str; + default = "plantuml"; + description = "Group which runs PlantUML server."; + }; + + home = mkOption { + type = types.str; + default = "/var/lib/plantuml"; + description = "Home directory of the PlantUML server instance."; + }; + + listenHost = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Host to listen on."; + }; + + listenPort = mkOption { + type = types.int; + default = 8080; + description = "Port to listen on."; + }; + + plantumlLimitSize = mkOption { + type = types.int; + default = 4096; + description = "Limits image width and height."; + }; + + graphvizPackage = mkOption { + type = types.package; + default = pkgs.graphviz; + defaultText = literalExpression "pkgs.graphviz"; + description = "Package containing the dot executable."; + }; + + plantumlStats = mkOption { + type = types.bool; + default = false; + description = "Set it to on to enable statistics report (https://plantuml.com/statistics-report)."; + }; + + httpAuthorization = mkOption { + type = types.nullOr types.str; + default = null; + description = "When calling the proxy endpoint, the value of HTTP_AUTHORIZATION will be used to set the HTTP Authorization header."; + }; + + allowPlantumlInclude = mkOption { + type = types.bool; + default = false; + description = "Enables !include processing which can read files from the server into diagrams. Files are read relative to the current working directory."; + }; + }; + }; + + config = mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + createHome = true; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.plantuml-server = { + description = "PlantUML server"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.home ]; + environment = { + PLANTUML_LIMIT_SIZE = builtins.toString cfg.plantumlLimitSize; + GRAPHVIZ_DOT = "${cfg.graphvizPackage}/bin/dot"; + PLANTUML_STATS = if cfg.plantumlStats then "on" else "off"; + HTTP_AUTHORIZATION = cfg.httpAuthorization; + ALLOW_PLANTUML_INCLUDE = if cfg.allowPlantumlInclude then "true" else "false"; + }; + script = '' + ${cfg.packages.jdk}/bin/java \ + -jar ${cfg.packages.jetty}/start.jar \ + --module=deploy,http,jsp \ + jetty.home=${cfg.packages.jetty} \ + jetty.base=${cfg.package} \ + jetty.http.host=${cfg.listenHost} \ + jetty.http.port=${builtins.toString cfg.listenPort} + ''; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + PrivateTmp = true; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ truh ]; +} diff --git a/nixos/modules/services/web-apps/plausible.nix b/nixos/modules/services/web-apps/plausible.nix new file mode 100644 index 00000000000..5d550ae5ca8 --- /dev/null +++ b/nixos/modules/services/web-apps/plausible.nix @@ -0,0 +1,292 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.plausible; + +in { + options.services.plausible = { + enable = mkEnableOption "plausible"; + + releaseCookiePath = mkOption { + type = with types; either str path; + description = '' + The path to the file with release cookie. (used for remote connection to the running node). + ''; + }; + + adminUser = { + name = mkOption { + default = "admin"; + type = types.str; + description = '' + Name of the admin user that plausible will created on initial startup. + ''; + }; + + email = mkOption { + type = types.str; + example = "admin@localhost"; + description = '' + Email-address of the admin-user. + ''; + }; + + passwordFile = mkOption { + type = types.either types.str types.path; + description = '' + Path to the file which contains the password of the admin user. + ''; + }; + + activate = mkEnableOption "activating the freshly created admin-user"; + }; + + database = { + clickhouse = { + setup = mkEnableOption "creating a clickhouse instance" // { default = true; }; + url = mkOption { + default = "http://localhost:8123/default"; + type = types.str; + description = '' + The URL to be used to connect to <package>clickhouse</package>. + ''; + }; + }; + postgres = { + setup = mkEnableOption "creating a postgresql instance" // { default = true; }; + dbname = mkOption { + default = "plausible"; + type = types.str; + description = '' + Name of the database to use. + ''; + }; + socket = mkOption { + default = "/run/postgresql"; + type = types.str; + description = '' + Path to the UNIX domain-socket to communicate with <package>postgres</package>. + ''; + }; + }; + }; + + server = { + disableRegistration = mkOption { + default = true; + type = types.bool; + description = '' + Whether to prohibit creating an account in plausible's UI. + ''; + }; + secretKeybaseFile = mkOption { + type = types.either types.path types.str; + description = '' + Path to the secret used by the <literal>phoenix</literal>-framework. Instructions + how to generate one are documented in the + <link xlink:href="https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Secret.html#content"> + framework docs</link>. + ''; + }; + port = mkOption { + default = 8000; + type = types.port; + description = '' + Port where the service should be available. + ''; + }; + baseUrl = mkOption { + type = types.str; + description = '' + Public URL where plausible is available. + + Note that <literal>/path</literal> components are currently ignored: + <link xlink:href="https://github.com/plausible/analytics/issues/1182"> + https://github.com/plausible/analytics/issues/1182 + </link>. + ''; + }; + }; + + mail = { + email = mkOption { + default = "hello@plausible.local"; + type = types.str; + description = '' + The email id to use for as <emphasis>from</emphasis> address of all communications + from Plausible. + ''; + }; + smtp = { + hostAddr = mkOption { + default = "localhost"; + type = types.str; + description = '' + The host address of your smtp server. + ''; + }; + hostPort = mkOption { + default = 25; + type = types.port; + description = '' + The port of your smtp server. + ''; + }; + user = mkOption { + default = null; + type = types.nullOr types.str; + description = '' + The username/email in case SMTP auth is enabled. + ''; + }; + passwordFile = mkOption { + default = null; + type = with types; nullOr (either str path); + description = '' + The path to the file with the password in case SMTP auth is enabled. + ''; + }; + enableSSL = mkEnableOption "SSL when connecting to the SMTP server"; + retries = mkOption { + type = types.ints.unsigned; + default = 2; + description = '' + Number of retries to make until mailer gives up. + ''; + }; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { assertion = cfg.adminUser.activate -> cfg.database.postgres.setup; + message = '' + Unable to automatically activate the admin-user if no locally managed DB for + postgres (`services.plausible.database.postgres.setup') is enabled! + ''; + } + ]; + + services.postgresql = mkIf cfg.database.postgres.setup { + enable = true; + }; + + services.clickhouse = mkIf cfg.database.clickhouse.setup { + enable = true; + }; + + services.epmd.enable = true; + + environment.systemPackages = [ pkgs.plausible ]; + + systemd.services = mkMerge [ + { + plausible = { + inherit (pkgs.plausible.meta) description; + documentation = [ "https://plausible.io/docs/self-hosting" ]; + wantedBy = [ "multi-user.target" ]; + after = optionals cfg.database.postgres.setup [ "postgresql.service" "plausible-postgres.service" ]; + requires = optional cfg.database.clickhouse.setup "clickhouse.service" + ++ optionals cfg.database.postgres.setup [ + "postgresql.service" + "plausible-postgres.service" + ]; + + environment = { + # NixOS specific option to avoid that it's trying to write into its store-path. + # See also https://github.com/lau/tzdata#data-directory-and-releases + STORAGE_DIR = "/var/lib/plausible/elixir_tzdata"; + + # Configuration options from + # https://plausible.io/docs/self-hosting-configuration + PORT = toString cfg.server.port; + DISABLE_REGISTRATION = boolToString cfg.server.disableRegistration; + + RELEASE_TMP = "/var/lib/plausible/tmp"; + # Home is needed to connect to the node with iex + HOME = "/var/lib/plausible"; + + ADMIN_USER_NAME = cfg.adminUser.name; + ADMIN_USER_EMAIL = cfg.adminUser.email; + + DATABASE_SOCKET_DIR = cfg.database.postgres.socket; + DATABASE_NAME = cfg.database.postgres.dbname; + CLICKHOUSE_DATABASE_URL = cfg.database.clickhouse.url; + + BASE_URL = cfg.server.baseUrl; + + MAILER_EMAIL = cfg.mail.email; + SMTP_HOST_ADDR = cfg.mail.smtp.hostAddr; + SMTP_HOST_PORT = toString cfg.mail.smtp.hostPort; + SMTP_RETRIES = toString cfg.mail.smtp.retries; + SMTP_HOST_SSL_ENABLED = boolToString cfg.mail.smtp.enableSSL; + + SELFHOST = "true"; + } // (optionalAttrs (cfg.mail.smtp.user != null) { + SMTP_USER_NAME = cfg.mail.smtp.user; + }); + + path = [ pkgs.plausible ] + ++ optional cfg.database.postgres.setup config.services.postgresql.package; + script = '' + export CONFIG_DIR=$CREDENTIALS_DIRECTORY + + export RELEASE_COOKIE="$(< $CREDENTIALS_DIRECTORY/RELEASE_COOKIE )" + + # setup + ${pkgs.plausible}/createdb.sh + ${pkgs.plausible}/migrate.sh + ${optionalString cfg.adminUser.activate '' + if ! ${pkgs.plausible}/init-admin.sh | grep 'already exists'; then + psql -d plausible <<< "UPDATE users SET email_verified=true;" + fi + ''} + + exec plausible start + ''; + + serviceConfig = { + DynamicUser = true; + PrivateTmp = true; + WorkingDirectory = "/var/lib/plausible"; + StateDirectory = "plausible"; + LoadCredential = [ + "ADMIN_USER_PWD:${cfg.adminUser.passwordFile}" + "SECRET_KEY_BASE:${cfg.server.secretKeybaseFile}" + "RELEASE_COOKIE:${cfg.releaseCookiePath}" + ] ++ lib.optionals (cfg.mail.smtp.passwordFile != null) [ "SMTP_USER_PWD:${cfg.mail.smtp.passwordFile}"]; + }; + }; + } + (mkIf cfg.database.postgres.setup { + # `plausible' requires the `citext'-extension. + plausible-postgres = { + after = [ "postgresql.service" ]; + partOf = [ "plausible.service" ]; + serviceConfig = { + Type = "oneshot"; + User = config.services.postgresql.superUser; + RemainAfterExit = true; + }; + script = with cfg.database.postgres; '' + PSQL() { + ${config.services.postgresql.package}/bin/psql --port=5432 "$@" + } + # check if the database already exists + if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${dbname} ; then + PSQL -tAc "CREATE ROLE plausible WITH LOGIN;" + PSQL -tAc "CREATE DATABASE ${dbname} WITH OWNER plausible;" + PSQL -d ${dbname} -tAc "CREATE EXTENSION IF NOT EXISTS citext;" + fi + ''; + }; + }) + ]; + }; + + meta.maintainers = with maintainers; [ ma27 ]; + meta.doc = ./plausible.xml; +} diff --git a/nixos/modules/services/web-apps/plausible.xml b/nixos/modules/services/web-apps/plausible.xml new file mode 100644 index 00000000000..92a571b9fbd --- /dev/null +++ b/nixos/modules/services/web-apps/plausible.xml @@ -0,0 +1,51 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-plausible"> + <title>Plausible</title> + <para> + <link xlink:href="https://plausible.io/">Plausible</link> is a privacy-friendly alternative to + Google analytics. + </para> + <section xml:id="module-services-plausible-basic-usage"> + <title>Basic Usage</title> + <para> + At first, a secret key is needed to be generated. This can be done with e.g. + <screen><prompt>$ </prompt>openssl rand -base64 64</screen> + </para> + <para> + After that, <package>plausible</package> can be deployed like this: +<programlisting>{ + services.plausible = { + <link linkend="opt-services.plausible.enable">enable</link> = true; + adminUser = { + <link linkend="opt-services.plausible.adminUser.activate">activate</link> = true; <co xml:id='ex-plausible-cfg-activate' /> + <link linkend="opt-services.plausible.adminUser.email">email</link> = "admin@localhost"; + <link linkend="opt-services.plausible.adminUser.passwordFile">passwordFile</link> = "/run/secrets/plausible-admin-pwd"; + }; + server = { + <link linkend="opt-services.plausible.server.baseUrl">baseUrl</link> = "http://analytics.example.org"; + <link linkend="opt-services.plausible.server.secretKeybaseFile">secretKeybaseFile</link> = "/run/secrets/plausible-secret-key-base"; <co xml:id='ex-plausible-cfg-secretbase' /> + }; + }; +}</programlisting> + <calloutlist> + <callout arearefs='ex-plausible-cfg-activate'> + <para> + <varname>activate</varname> is used to skip the email verification of the admin-user that's + automatically created by <package>plausible</package>. This is only supported if + <package>postgresql</package> is configured by the module. This is done by default, but + can be turned off with <xref linkend="opt-services.plausible.database.postgres.setup" />. + </para> + </callout> + <callout arearefs='ex-plausible-cfg-secretbase'> + <para> + <varname>secretKeybaseFile</varname> is a path to the file which contains the secret generated + with <package>openssl</package> as described above. + </para> + </callout> + </calloutlist> + </para> + </section> +</chapter> diff --git a/nixos/modules/services/web-apps/powerdns-admin.nix b/nixos/modules/services/web-apps/powerdns-admin.nix new file mode 100644 index 00000000000..4661ba80c5d --- /dev/null +++ b/nixos/modules/services/web-apps/powerdns-admin.nix @@ -0,0 +1,152 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.powerdns-admin; + + configText = '' + ${cfg.config} + '' + + optionalString (cfg.secretKeyFile != null) '' + with open('${cfg.secretKeyFile}') as file: + SECRET_KEY = file.read() + '' + + optionalString (cfg.saltFile != null) '' + with open('${cfg.saltFile}') as file: + SALT = file.read() + ''; +in +{ + options.services.powerdns-admin = { + enable = mkEnableOption "the PowerDNS web interface"; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = literalExpression '' + [ "-b" "127.0.0.1:8000" ] + ''; + description = '' + Extra arguments passed to powerdns-admin. + ''; + }; + + config = mkOption { + type = types.str; + default = ""; + example = '' + BIND_ADDRESS = '127.0.0.1' + PORT = 8000 + SQLALCHEMY_DATABASE_URI = 'postgresql://powerdnsadmin@/powerdnsadmin?host=/run/postgresql' + ''; + description = '' + Configuration python file. + See <link xlink:href="https://github.com/ngoduykhanh/PowerDNS-Admin/blob/v${pkgs.powerdns-admin.version}/configs/development.py">the example configuration</link> + for options. + ''; + }; + + secretKeyFile = mkOption { + type = types.nullOr types.path; + example = "/etc/powerdns-admin/secret"; + description = '' + The secret used to create cookies. + This needs to be set, otherwise the default is used and everyone can forge valid login cookies. + Set this to null to ignore this setting and configure it through another way. + ''; + }; + + saltFile = mkOption { + type = types.nullOr types.path; + example = "/etc/powerdns-admin/salt"; + description = '' + The salt used for serialization. + This should be set, otherwise the default is used. + Set this to null to ignore this setting and configure it through another way. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.powerdns-admin = { + description = "PowerDNS web interface"; + wantedBy = [ "multi-user.target" ]; + after = [ "networking.target" ]; + + environment.FLASK_CONF = builtins.toFile "powerdns-admin-config.py" configText; + environment.PYTHONPATH = pkgs.powerdns-admin.pythonPath; + serviceConfig = { + ExecStart = "${pkgs.powerdns-admin}/bin/powerdns-admin --pid /run/powerdns-admin/pid ${escapeShellArgs cfg.extraArgs}"; + ExecStartPre = "${pkgs.coreutils}/bin/env FLASK_APP=${pkgs.powerdns-admin}/share/powerdnsadmin/__init__.py ${pkgs.python3Packages.flask}/bin/flask db upgrade -d ${pkgs.powerdns-admin}/share/migrations"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + ExecStop = "${pkgs.coreutils}/bin/kill -TERM $MAINPID"; + PIDFile = "/run/powerdns-admin/pid"; + RuntimeDirectory = "powerdns-admin"; + User = "powerdnsadmin"; + Group = "powerdnsadmin"; + + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + BindReadOnlyPaths = [ + "/nix/store" + "-/etc/resolv.conf" + "-/etc/nsswitch.conf" + "-/etc/hosts" + "-/etc/localtime" + ] + ++ (optional (cfg.secretKeyFile != null) cfg.secretKeyFile) + ++ (optional (cfg.saltFile != null) cfg.saltFile); + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + # 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; + # Needs to start a server + #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 = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + # gunicorn needs setuid + SystemCallFilter = [ + "@system-service" + "~@privileged @resources @keyring" + # These got removed by the line above but are needed + "@setuid @chown" + ]; + TemporaryFileSystem = "/:ro"; + # Does not work well with the temporary root + #UMask = "0066"; + }; + }; + + users.groups.powerdnsadmin = { }; + users.users.powerdnsadmin = { + description = "PowerDNS web interface user"; + isSystemUser = true; + group = "powerdnsadmin"; + }; + }; + + # uses attributes of the linked package + meta.buildDocsInSandbox = false; +} diff --git a/nixos/modules/services/web-apps/prosody-filer.nix b/nixos/modules/services/web-apps/prosody-filer.nix new file mode 100644 index 00000000000..a901a95fd5f --- /dev/null +++ b/nixos/modules/services/web-apps/prosody-filer.nix @@ -0,0 +1,86 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + + cfg = config.services.prosody-filer; + + settingsFormat = pkgs.formats.toml { }; + configFile = settingsFormat.generate "prosody-filer.toml" cfg.settings; +in { + + options = { + services.prosody-filer = { + enable = mkEnableOption "Prosody Filer XMPP upload file server"; + + settings = mkOption { + description = '' + Configuration for Prosody Filer. + Refer to <link xlink:href="https://github.com/ThomasLeister/prosody-filer#configure-prosody-filer"/> for details on supported values. + ''; + + type = settingsFormat.type; + + example = { + secret = "mysecret"; + storeDir = "/srv/http/nginx/prosody-upload"; + }; + + defaultText = literalExpression '' + { + listenport = mkDefault "127.0.0.1:5050"; + uploadSubDir = mkDefault "upload/"; + } + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.prosody-filer.settings = { + listenport = mkDefault "127.0.0.1:5050"; + uploadSubDir = mkDefault "upload/"; + }; + + users.users.prosody-filer = { + group = "prosody-filer"; + isSystemUser = true; + }; + + users.groups.prosody-filer = { }; + + systemd.services.prosody-filer = { + description = "Prosody file upload server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + User = "prosody-filer"; + Group = "prosody-filer"; + ExecStart = "${pkgs.prosody-filer}/bin/prosody-filer -config ${configFile}"; + Restart = "on-failure"; + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectHome = true; + ProtectClock = true; + ProtectProc = "noaccess"; + ProcSubset = "pid"; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectHostname = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + RestrictNamespaces = true; + LockPersonality = true; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/restya-board.nix b/nixos/modules/services/web-apps/restya-board.nix new file mode 100644 index 00000000000..4b36cc8754c --- /dev/null +++ b/nixos/modules/services/web-apps/restya-board.nix @@ -0,0 +1,380 @@ +{ config, lib, pkgs, ... }: + +with lib; + +# TODO: are these php-packages needed? +#imagick +#php-geoip -> php.ini: extension = geoip.so +#expat + +let + cfg = config.services.restya-board; + fpm = config.services.phpfpm.pools.${poolName}; + + runDir = "/run/restya-board"; + + poolName = "restya-board"; + +in + +{ + + ###### interface + + options = { + + services.restya-board = { + + enable = mkEnableOption "restya-board"; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/restya-board"; + description = '' + Data of the application. + ''; + }; + + user = mkOption { + type = types.str; + default = "restya-board"; + description = '' + User account under which the web-application runs. + ''; + }; + + group = mkOption { + type = types.str; + default = "nginx"; + description = '' + Group account under which the web-application runs. + ''; + }; + + virtualHost = { + serverName = mkOption { + type = types.str; + default = "restya.board"; + description = '' + Name of the nginx virtualhost to use. + ''; + }; + + listenHost = mkOption { + type = types.str; + default = "localhost"; + description = '' + Listen address for the virtualhost to use. + ''; + }; + + listenPort = mkOption { + type = types.int; + default = 3000; + description = '' + Listen port for the virtualhost to use. + ''; + }; + }; + + database = { + host = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Host of the database. Leave 'null' to use a local PostgreSQL database. + A local PostgreSQL database is initialized automatically. + ''; + }; + + port = mkOption { + type = types.nullOr types.int; + default = 5432; + description = '' + The database's port. + ''; + }; + + name = mkOption { + type = types.str; + default = "restya_board"; + description = '' + Name of the database. The database must exist. + ''; + }; + + user = mkOption { + type = types.str; + default = "restya_board"; + description = '' + The database user. The user must exist and have access to + the specified database. + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The database user's password. 'null' if no password is set. + ''; + }; + }; + + email = { + server = mkOption { + type = types.nullOr types.str; + default = null; + example = "localhost"; + description = '' + Hostname to send outgoing mail. Null to use the system MTA. + ''; + }; + + port = mkOption { + type = types.int; + default = 25; + description = '' + Port used to connect to SMTP server. + ''; + }; + + login = mkOption { + type = types.str; + default = ""; + description = '' + SMTP authentication login used when sending outgoing mail. + ''; + }; + + password = mkOption { + type = types.str; + default = ""; + description = '' + SMTP authentication password used when sending outgoing mail. + + ATTENTION: The password is stored world-readable in the nix-store! + ''; + }; + }; + + timezone = mkOption { + type = types.lines; + default = "GMT"; + description = '' + Timezone the web-app runs in. + ''; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + services.phpfpm.pools = { + ${poolName} = { + inherit (cfg) user group; + + phpOptions = '' + date.timezone = "CET" + + ${optionalString (cfg.email.server != null) '' + SMTP = ${cfg.email.server} + smtp_port = ${toString cfg.email.port} + auth_username = ${cfg.email.login} + auth_password = ${cfg.email.password} + ''} + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0600"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = 1; + }; + }; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts.${cfg.virtualHost.serverName} = { + listen = [ { addr = cfg.virtualHost.listenHost; port = cfg.virtualHost.listenPort; } ]; + serverName = cfg.virtualHost.serverName; + root = runDir; + extraConfig = '' + index index.html index.php; + + gzip on; + + gzip_comp_level 6; + gzip_min_length 1100; + gzip_buffers 16 8k; + gzip_proxied any; + gzip_types text/plain application/xml text/css text/js text/xml application/x-javascript text/javascript application/json application/xml+rss; + + client_max_body_size 300M; + + rewrite ^/oauth/authorize$ /server/php/authorize.php last; + rewrite ^/oauth_callback/([a-zA-Z0-9_\.]*)/([a-zA-Z0-9_\.]*)$ /server/php/oauth_callback.php?plugin=$1&code=$2 last; + rewrite ^/download/([0-9]*)/([a-zA-Z0-9_\.]*)$ /server/php/download.php?id=$1&hash=$2 last; + rewrite ^/ical/([0-9]*)/([0-9]*)/([a-z0-9]*).ics$ /server/php/ical.php?board_id=$1&user_id=$2&hash=$3 last; + rewrite ^/api/(.*)$ /server/php/R/r.php?_url=$1&$args last; + rewrite ^/api_explorer/api-docs/$ /client/api_explorer/api-docs/index.php last; + ''; + + locations."/".root = "${runDir}/client"; + + locations."~ \\.php$" = { + tryFiles = "$uri =404"; + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_pass unix:${fpm.socket}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PHP_VALUE "upload_max_filesize=9G \n post_max_size=9G \n max_execution_time=200 \n max_input_time=200 \n memory_limit=256M"; + ''; + }; + + locations."~* \\.(css|js|less|html|ttf|woff|jpg|jpeg|gif|png|bmp|ico)" = { + root = "${runDir}/client"; + extraConfig = '' + if (-f $request_filename) { + break; + } + rewrite ^/img/([a-zA-Z_]*)/([a-zA-Z_]*)/([a-zA-Z0-9_\.]*)$ /server/php/image.php?size=$1&model=$2&filename=$3 last; + add_header Cache-Control public; + add_header Cache-Control must-revalidate; + expires 7d; + ''; + }; + }; + + systemd.services.restya-board-init = { + description = "Restya board initialization"; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + + wantedBy = [ "multi-user.target" ]; + requires = [ "postgresql.service" ]; + after = [ "network.target" "postgresql.service" ]; + + script = '' + rm -rf "${runDir}" + mkdir -m 750 -p "${runDir}" + cp -r "${pkgs.restya-board}/"* "${runDir}" + sed -i "s/@restya.com/@${cfg.virtualHost.serverName}/g" "${runDir}/sql/restyaboard_with_empty_data.sql" + rm -rf "${runDir}/media" + rm -rf "${runDir}/client/img" + chmod -R 0750 "${runDir}" + + sed -i "s@^php@${config.services.phpfpm.phpPackage}/bin/php@" "${runDir}/server/php/shell/"*.sh + + ${if (cfg.database.host == null) then '' + sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', 'localhost');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', 'restya');/g" "${runDir}/server/php/config.inc.php" + '' else '' + sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', '${cfg.database.host}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', ${if cfg.database.passwordFile == null then "''" else "'file_get_contents(${cfg.database.passwordFile})'"});/g" "${runDir}/server/php/config.inc.php + ''} + sed -i "s/^.*'R_DB_PORT'.*$/define('R_DB_PORT', '${toString cfg.database.port}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_NAME'.*$/define('R_DB_NAME', '${cfg.database.name}');/g" "${runDir}/server/php/config.inc.php" + sed -i "s/^.*'R_DB_USER'.*$/define('R_DB_USER', '${cfg.database.user}');/g" "${runDir}/server/php/config.inc.php" + + chmod 0400 "${runDir}/server/php/config.inc.php" + + ln -sf "${cfg.dataDir}/media" "${runDir}/media" + ln -sf "${cfg.dataDir}/client/img" "${runDir}/client/img" + + chmod g+w "${runDir}/tmp/cache" + chown -R "${cfg.user}"."${cfg.group}" "${runDir}" + + + mkdir -m 0750 -p "${cfg.dataDir}" + mkdir -m 0750 -p "${cfg.dataDir}/media" + mkdir -m 0750 -p "${cfg.dataDir}/client/img" + cp -r "${pkgs.restya-board}/media/"* "${cfg.dataDir}/media" + cp -r "${pkgs.restya-board}/client/img/"* "${cfg.dataDir}/client/img" + chown "${cfg.user}"."${cfg.group}" "${cfg.dataDir}" + chown -R "${cfg.user}"."${cfg.group}" "${cfg.dataDir}/media" + chown -R "${cfg.user}"."${cfg.group}" "${cfg.dataDir}/client/img" + + ${optionalString (cfg.database.host == null) '' + if ! [ -e "${cfg.dataDir}/.db-initialized" ]; then + ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \ + ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \ + -c "CREATE USER ${cfg.database.user} WITH ENCRYPTED PASSWORD 'restya'" + + ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \ + ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \ + -c "CREATE DATABASE ${cfg.database.name} OWNER ${cfg.database.user} ENCODING 'UTF8' TEMPLATE template0" + + ${pkgs.sudo}/bin/sudo -u ${cfg.user} \ + ${config.services.postgresql.package}/bin/psql -U ${cfg.database.user} \ + -d ${cfg.database.name} -f "${runDir}/sql/restyaboard_with_empty_data.sql" + + touch "${cfg.dataDir}/.db-initialized" + fi + ''} + ''; + }; + + systemd.timers.restya-board = { + description = "restya-board scripts for e.g. email notification"; + wantedBy = [ "timers.target" ]; + after = [ "restya-board-init.service" ]; + requires = [ "restya-board-init.service" ]; + timerConfig = { + OnUnitInactiveSec = "60s"; + Unit = "restya-board-timers.service"; + }; + }; + + systemd.services.restya-board-timers = { + description = "restya-board scripts for e.g. email notification"; + serviceConfig.Type = "oneshot"; + serviceConfig.User = cfg.user; + + after = [ "restya-board-init.service" ]; + requires = [ "restya-board-init.service" ]; + + script = '' + /bin/sh ${runDir}/server/php/shell/instant_email_notification.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/periodic_email_notification.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/imap.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/webhook.sh 2> /dev/null || true + /bin/sh ${runDir}/server/php/shell/card_due_notification.sh 2> /dev/null || true + ''; + }; + + users.users.restya-board = { + isSystemUser = true; + createHome = false; + home = runDir; + group = "restya-board"; + }; + users.groups.restya-board = {}; + + services.postgresql.enable = mkIf (cfg.database.host == null) true; + + services.postgresql.identMap = optionalString (cfg.database.host == null) + '' + restya-board-users restya-board restya_board + ''; + + services.postgresql.authentication = optionalString (cfg.database.host == null) + '' + local restya_board all ident map=restya-board-users + ''; + + }; + +} + diff --git a/nixos/modules/services/web-apps/rss-bridge.nix b/nixos/modules/services/web-apps/rss-bridge.nix new file mode 100644 index 00000000000..f2b6d955982 --- /dev/null +++ b/nixos/modules/services/web-apps/rss-bridge.nix @@ -0,0 +1,125 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.rss-bridge; + + poolName = "rss-bridge"; + + whitelist = pkgs.writeText "rss-bridge_whitelist.txt" + (concatStringsSep "\n" cfg.whitelist); +in +{ + options = { + services.rss-bridge = { + enable = mkEnableOption "rss-bridge"; + + user = mkOption { + type = types.str; + default = "nginx"; + description = '' + User account under which both the service and the web-application run. + ''; + }; + + group = mkOption { + type = types.str; + default = "nginx"; + description = '' + Group under which the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = poolName; + description = '' + Name of existing phpfpm pool that is used to run web-application. + If not specified a pool will be created automatically with + default values. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/rss-bridge"; + description = '' + Location in which cache directory will be created. + You can put <literal>config.ini.php</literal> in here. + ''; + }; + + virtualHost = mkOption { + type = types.nullOr types.str; + default = "rss-bridge"; + description = '' + Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost. + ''; + }; + + whitelist = mkOption { + type = types.listOf types.str; + default = []; + example = options.literalExpression '' + [ + "Facebook" + "Instagram" + "Twitter" + ] + ''; + description = '' + List of bridges to be whitelisted. + If the list is empty, rss-bridge will use whitelist.default.txt. + Use <literal>[ "*" ]</literal> to whitelist all. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.phpfpm.pools = mkIf (cfg.pool == poolName) { + ${poolName} = { + user = cfg.user; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = cfg.user; + "listen.group" = cfg.user; + "listen.mode" = "0600"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = 1; + }; + }; + }; + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}/cache' 0750 ${cfg.user} ${cfg.group} - -" + (mkIf (cfg.whitelist != []) "L+ ${cfg.dataDir}/whitelist.txt - - - - ${whitelist}") + "z '${cfg.dataDir}/config.ini.php' 0750 ${cfg.user} ${cfg.group} - -" + ]; + + services.nginx = mkIf (cfg.virtualHost != null) { + enable = true; + virtualHosts = { + ${cfg.virtualHost} = { + root = "${pkgs.rss-bridge}"; + + locations."/" = { + tryFiles = "$uri /index.php$is_args$args"; + }; + + locations."~ ^/index.php(/|$)" = { + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket}; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param RSSBRIDGE_DATA ${cfg.dataDir}; + ''; + }; + }; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/selfoss.nix b/nixos/modules/services/web-apps/selfoss.nix new file mode 100644 index 00000000000..899976ac696 --- /dev/null +++ b/nixos/modules/services/web-apps/selfoss.nix @@ -0,0 +1,164 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.selfoss; + + poolName = "selfoss_pool"; + + dataDir = "/var/lib/selfoss"; + + selfoss-config = + let + db_type = cfg.database.type; + default_port = if (db_type == "mysql") then 3306 else 5342; + in + pkgs.writeText "selfoss-config.ini" '' + [globals] + ${lib.optionalString (db_type != "sqlite") '' + db_type=${db_type} + db_host=${cfg.database.host} + db_database=${cfg.database.name} + db_username=${cfg.database.user} + db_password=${cfg.database.password} + db_port=${toString (if (cfg.database.port != null) then cfg.database.port + else default_port)} + '' + } + ${cfg.extraConfig} + ''; +in + { + options = { + services.selfoss = { + enable = mkEnableOption "selfoss"; + + user = mkOption { + type = types.str; + default = "nginx"; + description = '' + User account under which both the service and the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = "${poolName}"; + description = '' + Name of existing phpfpm pool that is used to run web-application. + If not specified a pool will be created automatically with + default values. + ''; + }; + + database = { + type = mkOption { + type = types.enum ["pgsql" "mysql" "sqlite"]; + default = "sqlite"; + description = '' + Database to store feeds. Supported are sqlite, pgsql and mysql. + ''; + }; + + host = mkOption { + type = types.str; + default = "localhost"; + description = '' + Host of the database (has no effect if type is "sqlite"). + ''; + }; + + name = mkOption { + type = types.str; + default = "tt_rss"; + description = '' + Name of the existing database (has no effect if type is "sqlite"). + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = '' + The database user. The user must exist and has access to + the specified database (has no effect if type is "sqlite"). + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The database user's password (has no effect if type is "sqlite"). + ''; + }; + + port = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + The database's port. If not set, the default ports will be + provided (5432 and 3306 for pgsql and mysql respectively) + (has no effect if type is "sqlite"). + ''; + }; + }; + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Extra configuration added to config.ini + ''; + }; + }; + }; + + config = mkIf cfg.enable { + services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") { + ${poolName} = { + user = "nginx"; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0600"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = 1; + }; + }; + }; + + systemd.services.selfoss-config = { + serviceConfig.Type = "oneshot"; + script = '' + mkdir -m 755 -p ${dataDir} + cd ${dataDir} + + # Delete all but the "data" folder + ls | grep -v data | while read line; do rm -rf $line; done || true + + # Create the files + cp -r "${pkgs.selfoss}/"* "${dataDir}" + ln -sf "${selfoss-config}" "${dataDir}/config.ini" + chown -R "${cfg.user}" "${dataDir}" + chmod -R 755 "${dataDir}" + ''; + wantedBy = [ "multi-user.target" ]; + }; + + systemd.services.selfoss-update = { + serviceConfig = { + ExecStart = "${pkgs.php}/bin/php ${dataDir}/cliupdate.php"; + User = "${cfg.user}"; + }; + startAt = "hourly"; + after = [ "selfoss-config.service" ]; + wantedBy = [ "multi-user.target" ]; + + }; + + }; +} diff --git a/nixos/modules/services/web-apps/shiori.nix b/nixos/modules/services/web-apps/shiori.nix new file mode 100644 index 00000000000..bb2fc684e83 --- /dev/null +++ b/nixos/modules/services/web-apps/shiori.nix @@ -0,0 +1,96 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.shiori; +in { + options = { + services.shiori = { + enable = mkEnableOption "Shiori simple bookmarks manager"; + + package = mkOption { + type = types.package; + default = pkgs.shiori; + defaultText = literalExpression "pkgs.shiori"; + description = "The Shiori package to use."; + }; + + address = mkOption { + type = types.str; + default = ""; + description = '' + The IP address on which Shiori will listen. + If empty, listens on all interfaces. + ''; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = "The port of the Shiori web application"; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.shiori = with cfg; { + description = "Shiori simple bookmarks manager"; + wantedBy = [ "multi-user.target" ]; + + environment.SHIORI_DIR = "/var/lib/shiori"; + + serviceConfig = { + ExecStart = "${package}/bin/shiori serve --address '${address}' --port '${toString port}'"; + + DynamicUser = true; + StateDirectory = "shiori"; + # As the RootDirectory + RuntimeDirectory = "shiori"; + + # Security options + + BindReadOnlyPaths = [ + "/nix/store" + + # For SSL certificates, and the resolv.conf + "/etc" + ]; + + CapabilityBoundingSet = ""; + + DeviceAllow = ""; + + LockPersonality = true; + + MemoryDenyWriteExecute = true; + + PrivateDevices = true; + PrivateUsers = true; + + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + + RestrictNamespaces = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictRealtime = true; + RestrictSUIDSGID = true; + + RootDirectory = "/run/shiori"; + + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + SystemCallFilter = [ + "@system-service" + "~@cpu-emulation" "~@debug" "~@keyring" "~@memlock" "~@obsolete" "~@privileged" "~@resources" "~@setuid" + ]; + }; + }; + }; + + meta.maintainers = with maintainers; [ minijackson ]; +} diff --git a/nixos/modules/services/web-apps/sogo.nix b/nixos/modules/services/web-apps/sogo.nix new file mode 100644 index 00000000000..4610bb96cb5 --- /dev/null +++ b/nixos/modules/services/web-apps/sogo.nix @@ -0,0 +1,271 @@ +{ config, pkgs, lib, ... }: with lib; let + cfg = config.services.sogo; + + preStart = pkgs.writeShellScriptBin "sogo-prestart" '' + touch /etc/sogo/sogo.conf + chown sogo:sogo /etc/sogo/sogo.conf + chmod 640 /etc/sogo/sogo.conf + + ${if (cfg.configReplaces != {}) then '' + # Insert secrets + ${concatStringsSep "\n" (mapAttrsToList (k: v: ''export ${k}="$(cat "${v}" | tr -d '\n')"'') cfg.configReplaces)} + + ${pkgs.perl}/bin/perl -p ${concatStringsSep " " (mapAttrsToList (k: v: '' -e 's/${k}/''${ENV{"${k}"}}/g;' '') cfg.configReplaces)} /etc/sogo/sogo.conf.raw > /etc/sogo/sogo.conf + '' else '' + cp /etc/sogo/sogo.conf.raw /etc/sogo/sogo.conf + ''} + ''; + +in { + options.services.sogo = with types; { + enable = mkEnableOption "SOGo groupware"; + + vhostName = mkOption { + description = "Name of the nginx vhost"; + type = str; + default = "sogo"; + }; + + timezone = mkOption { + description = "Timezone of your SOGo instance"; + type = str; + example = "America/Montreal"; + }; + + language = mkOption { + description = "Language of SOGo"; + type = str; + default = "English"; + }; + + ealarmsCredFile = mkOption { + description = "Optional path to a credentials file for email alarms"; + type = nullOr str; + default = null; + }; + + configReplaces = mkOption { + description = '' + Replacement-filepath mapping for sogo.conf. + Every key is replaced with the contents of the file specified as value. + + In the example, every occurence of LDAP_BINDPW will be replaced with the text of the + specified file. + ''; + type = attrsOf str; + default = {}; + example = { + LDAP_BINDPW = "/var/lib/secrets/sogo/ldappw"; + }; + }; + + extraConfig = mkOption { + description = "Extra sogo.conf configuration lines"; + type = lines; + default = ""; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ pkgs.sogo ]; + + environment.etc."sogo/sogo.conf.raw".text = '' + { + // Mandatory parameters + SOGoTimeZone = "${cfg.timezone}"; + SOGoLanguage = "${cfg.language}"; + // Paths + WOSendMail = "/run/wrappers/bin/sendmail"; + SOGoMailSpoolPath = "/var/lib/sogo/spool"; + // Enable CSRF protection + SOGoXSRFValidationEnabled = YES; + // Remove dates from log (jornald does that) + NGLogDefaultLogEventFormatterClass = "NGLogEventFormatter"; + // Extra config + ${cfg.extraConfig} + } + ''; + + systemd.services.sogo = { + description = "SOGo groupware"; + after = [ "postgresql.service" "mysql.service" "memcached.service" "openldap.service" "dovecot2.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ]; + + environment.LDAPTLS_CACERT = "/etc/ssl/certs/ca-certificates.crt"; + + serviceConfig = { + Type = "forking"; + ExecStartPre = "+" + preStart + "/bin/sogo-prestart"; + ExecStart = "${pkgs.sogo}/bin/sogod -WOLogFile - -WOPidFile /run/sogo/sogo.pid"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RuntimeDirectory = "sogo"; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + MemoryDenyWriteExecute = true; + SystemCallFilter = "@basic-io @file-system @network-io @system-service @timer"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6"; + }; + }; + + systemd.services.sogo-tmpwatch = { + description = "SOGo tmpwatch"; + + startAt = [ "hourly" ]; + script = '' + SOGOSPOOL=/var/lib/sogo/spool + + find "$SOGOSPOOL" -type f -user sogo -atime +23 -delete > /dev/null + find "$SOGOSPOOL" -mindepth 1 -type d -user sogo -empty -delete > /dev/null + ''; + + serviceConfig = { + Type = "oneshot"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + PrivateNetwork = true; + SystemCallFilter = "@basic-io @file-system @system-service"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = ""; + }; + }; + + systemd.services.sogo-ealarms = { + description = "SOGo email alarms"; + + after = [ "postgresql.service" "mysqld.service" "memcached.service" "openldap.service" "dovecot2.service" "sogo.service" ]; + restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ]; + + startAt = [ "minutely" ]; + + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.sogo}/bin/sogo-ealarms-notify${optionalString (cfg.ealarmsCredFile != null) " -p ${cfg.ealarmsCredFile}"}"; + + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + StateDirectory = "sogo/spool"; + + User = "sogo"; + Group = "sogo"; + + CapabilityBoundingSet = ""; + NoNewPrivileges = true; + + LockPersonality = true; + RestrictRealtime = true; + PrivateMounts = true; + PrivateUsers = true; + MemoryDenyWriteExecute = true; + SystemCallFilter = "@basic-io @file-system @network-io @system-service"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6"; + }; + }; + + # nginx vhost + services.nginx.virtualHosts."${cfg.vhostName}" = { + locations."/".extraConfig = '' + rewrite ^ https://$server_name/SOGo; + allow all; + ''; + + # For iOS 7 + locations."/principals/".extraConfig = '' + rewrite ^ https://$server_name/SOGo/dav; + allow all; + ''; + + locations."^~/SOGo".extraConfig = '' + proxy_pass http://127.0.0.1:20000; + proxy_redirect http://127.0.0.1:20000 default; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_set_header x-webobjects-server-protocol HTTP/1.0; + proxy_set_header x-webobjects-remote-host 127.0.0.1; + proxy_set_header x-webobjects-server-port $server_port; + proxy_set_header x-webobjects-server-name $server_name; + proxy_set_header x-webobjects-server-url $scheme://$host; + proxy_connect_timeout 90; + proxy_send_timeout 90; + proxy_read_timeout 90; + proxy_buffer_size 4k; + proxy_buffers 4 32k; + proxy_busy_buffers_size 64k; + proxy_temp_file_write_size 64k; + client_max_body_size 50m; + client_body_buffer_size 128k; + break; + ''; + + locations."/SOGo.woa/WebServerResources/".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/; + allow all; + ''; + + locations."/SOGo/WebServerResources/".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/; + allow all; + ''; + + locations."~ ^/SOGo/so/ControlPanel/Products/([^/]*)/Resources/(.*)$".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; + ''; + + locations."~ ^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\\.(jpg|png|gif|css|js)$".extraConfig = '' + alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; + ''; + }; + + # User and group + users.groups.sogo = {}; + users.users.sogo = { + group = "sogo"; + isSystemUser = true; + description = "SOGo service user"; + }; + }; +} diff --git a/nixos/modules/services/web-apps/timetagger.nix b/nixos/modules/services/web-apps/timetagger.nix new file mode 100644 index 00000000000..373f4fcd52f --- /dev/null +++ b/nixos/modules/services/web-apps/timetagger.nix @@ -0,0 +1,80 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkEnableOption mkIf mkOption types literalExpression; + + cfg = config.services.timetagger; +in { + + options = { + services.timetagger = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Tag your time, get the insight + + <note><para> + This app does not do authentication. + You must setup authentication yourself or run it in an environment where + only allowed users have access. + </para></note> + ''; + }; + + bindAddr = mkOption { + description = "Address to bind to."; + type = types.str; + default = "127.0.0.1"; + }; + + port = mkOption { + description = "Port to bind to."; + type = types.port; + default = 8080; + }; + + package = mkOption { + description = '' + Use own package for starting timetagger web application. + + The ${literalExpression ''pkgs.timetagger''} package only provides a + "run.py" script for the actual package + ${literalExpression ''pkgs.python3Packages.timetagger''}. + + If you want to provide a "run.py" script for starting timetagger + yourself, you can do so with this option. + If you do so, the 'bindAddr' and 'port' options are ignored. + ''; + + default = pkgs.timetagger.override { addr = cfg.bindAddr; port = cfg.port; }; + defaultText = literalExpression '' + pkgs.timetagger.override { + addr = ${cfg.bindAddr}; + port = ${cfg.port}; + }; + ''; + type = types.package; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.timetagger = { + description = "Timetagger service"; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + User = "timetagger"; + Group = "timetagger"; + StateDirectory = "timetagger"; + + ExecStart = "${cfg.package}/bin/timetagger"; + + Restart = "on-failure"; + RestartSec = 1; + }; + }; + }; +} + diff --git a/nixos/modules/services/web-apps/trilium.nix b/nixos/modules/services/web-apps/trilium.nix new file mode 100644 index 00000000000..35383c992fe --- /dev/null +++ b/nixos/modules/services/web-apps/trilium.nix @@ -0,0 +1,146 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.trilium-server; + configIni = pkgs.writeText "trilium-config.ini" '' + [General] + # Instance name can be used to distinguish between different instances + instanceName=${cfg.instanceName} + + # Disable automatically generating desktop icon + noDesktopIcon=true + noBackup=${lib.boolToString cfg.noBackup} + + [Network] + # host setting is relevant only for web deployments - set the host on which the server will listen + host=${cfg.host} + # port setting is relevant only for web deployments, desktop builds run on random free port + port=${toString cfg.port} + # true for TLS/SSL/HTTPS (secure), false for HTTP (unsecure). + https=false + ''; +in +{ + + options.services.trilium-server = with lib; { + enable = mkEnableOption "trilium-server"; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/trilium"; + description = '' + The directory storing the notes database and the configuration. + ''; + }; + + instanceName = mkOption { + type = types.str; + default = "Trilium"; + description = '' + Instance name used to distinguish between different instances + ''; + }; + + noBackup = mkOption { + type = types.bool; + default = false; + description = '' + Disable periodic database backups. + ''; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = '' + The host address to bind to (defaults to localhost). + ''; + }; + + port = mkOption { + type = types.int; + default = 8080; + description = '' + The port number to bind to. + ''; + }; + + nginx = mkOption { + default = {}; + description = '' + Configuration for nginx reverse proxy. + ''; + + type = types.submodule { + options = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Configure the nginx reverse proxy settings. + ''; + }; + + hostName = mkOption { + type = types.str; + description = '' + The hostname use to setup the virtualhost configuration + ''; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + meta.maintainers = with lib.maintainers; [ fliegendewurst ]; + + users.groups.trilium = {}; + users.users.trilium = { + description = "Trilium User"; + group = "trilium"; + home = cfg.dataDir; + isSystemUser = true; + }; + + systemd.services.trilium-server = { + wantedBy = [ "multi-user.target" ]; + environment.TRILIUM_DATA_DIR = cfg.dataDir; + serviceConfig = { + ExecStart = "${pkgs.trilium-server}/bin/trilium-server"; + User = "trilium"; + Group = "trilium"; + PrivateTmp = "true"; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 trilium trilium - -" + "L+ ${cfg.dataDir}/config.ini - - - - ${configIni}" + ]; + + } + + (lib.mkIf cfg.nginx.enable { + services.nginx = { + enable = true; + virtualHosts."${cfg.nginx.hostName}" = { + locations."/" = { + proxyPass = "http://${cfg.host}:${toString cfg.port}/"; + extraConfig = '' + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + ''; + }; + extraConfig = '' + client_max_body_size 0; + ''; + }; + }; + }) + ]); +} diff --git a/nixos/modules/services/web-apps/tt-rss.nix b/nixos/modules/services/web-apps/tt-rss.nix new file mode 100644 index 00000000000..9aa38ab25c9 --- /dev/null +++ b/nixos/modules/services/web-apps/tt-rss.nix @@ -0,0 +1,686 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.tt-rss; + + configVersion = 26; + + dbPort = if cfg.database.port == null + then (if cfg.database.type == "pgsql" then 5432 else 3306) + else cfg.database.port; + + poolName = "tt-rss"; + + mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql"; + + tt-rss-config = let + password = + if (cfg.database.password != null) then + "'${(escape ["'" "\\"] cfg.database.password)}'" + else if (cfg.database.passwordFile != null) then + "file_get_contents('${cfg.database.passwordFile}')" + else + null + ; + in pkgs.writeText "config.php" '' + <?php + putenv('TTRSS_PHP_EXECUTABLE=${pkgs.php}/bin/php'); + + putenv('TTRSS_LOCK_DIRECTORY=${cfg.root}/lock'); + putenv('TTRSS_CACHE_DIR=${cfg.root}/cache'); + putenv('TTRSS_ICONS_DIR=${cfg.root}/feed-icons'); + putenv('TTRSS_ICONS_URL=feed-icons'); + putenv('TTRSS_SELF_URL_PATH=${cfg.selfUrlPath}'); + + putenv('TTRSS_MYSQL_CHARSET=UTF8'); + + putenv('TTRSS_DB_TYPE=${cfg.database.type}'); + putenv('TTRSS_DB_HOST=${optionalString (cfg.database.host != null) cfg.database.host}'); + putenv('TTRSS_DB_USER=${cfg.database.user}'); + putenv('TTRSS_DB_NAME=${cfg.database.name}'); + putenv('TTRSS_DB_PASS=' ${optionalString (password != null) ". ${password}"}); + putenv('TTRSS_DB_PORT=${toString dbPort}'); + + putenv('TTRSS_AUTH_AUTO_CREATE=${boolToString cfg.auth.autoCreate}'); + putenv('TTRSS_AUTH_AUTO_LOGIN=${boolToString cfg.auth.autoLogin}'); + + putenv('TTRSS_FEED_CRYPT_KEY=${escape ["'" "\\"] cfg.feedCryptKey}'); + + + putenv('TTRSS_SINGLE_USER_MODE=${boolToString cfg.singleUserMode}'); + + putenv('TTRSS_SIMPLE_UPDATE_MODE=${boolToString cfg.simpleUpdateMode}'); + + # Never check for updates - the running version of the code should + # be controlled entirely by the version of TT-RSS active in the + # current Nix profile. If TT-RSS updates itself to a version + # requiring a database schema upgrade, and then the SystemD + # tt-rss.service is restarted, the old code copied from the Nix + # store will overwrite the updated version, causing the code to + # detect the need for a schema "upgrade" (since the schema version + # in the database is different than in the code), but the update + # schema operation in TT-RSS will do nothing because the schema + # version in the database is newer than that in the code. + putenv('TTRSS_CHECK_FOR_UPDATES=false'); + + putenv('TTRSS_FORCE_ARTICLE_PURGE=${toString cfg.forceArticlePurge}'); + putenv('TTRSS_SESSION_COOKIE_LIFETIME=${toString cfg.sessionCookieLifetime}'); + putenv('TTRSS_ENABLE_GZIP_OUTPUT=${boolToString cfg.enableGZipOutput}'); + + putenv('TTRSS_PLUGINS=${builtins.concatStringsSep "," cfg.plugins}'); + + putenv('TTRSS_LOG_DESTINATION=${cfg.logDestination}'); + putenv('TTRSS_CONFIG_VERSION=${toString configVersion}'); + + + putenv('TTRSS_PUBSUBHUBBUB_ENABLED=${boolToString cfg.pubSubHubbub.enable}'); + putenv('TTRSS_PUBSUBHUBBUB_HUB=${cfg.pubSubHubbub.hub}'); + + putenv('TTRSS_SPHINX_SERVER=${cfg.sphinx.server}'); + putenv('TTRSS_SPHINX_INDEX=${builtins.concatStringsSep "," cfg.sphinx.index}'); + + putenv('TTRSS_ENABLE_REGISTRATION=${boolToString cfg.registration.enable}'); + putenv('TTRSS_REG_NOTIFY_ADDRESS=${cfg.registration.notifyAddress}'); + putenv('TTRSS_REG_MAX_USERS=${toString cfg.registration.maxUsers}'); + + putenv('TTRSS_SMTP_SERVER=${cfg.email.server}'); + putenv('TTRSS_SMTP_LOGIN=${cfg.email.login}'); + putenv('TTRSS_SMTP_PASSWORD=${escape ["'" "\\"] cfg.email.password}'); + putenv('TTRSS_SMTP_SECURE=${cfg.email.security}'); + + putenv('TTRSS_SMTP_FROM_NAME=${escape ["'" "\\"] cfg.email.fromName}'); + putenv('TTRSS_SMTP_FROM_ADDRESS=${escape ["'" "\\"] cfg.email.fromAddress}'); + putenv('TTRSS_DIGEST_SUBJECT=${escape ["'" "\\"] cfg.email.digestSubject}'); + + ${cfg.extraConfig} + ''; + + # tt-rss and plugins and themes and config.php + servedRoot = pkgs.runCommand "tt-rss-served-root" {} '' + cp --no-preserve=mode -r ${pkgs.tt-rss} $out + cp ${tt-rss-config} $out/config.php + ${optionalString (cfg.pluginPackages != []) '' + for plugin in ${concatStringsSep " " cfg.pluginPackages}; do + cp -r "$plugin"/* "$out/plugins.local/" + done + ''} + ${optionalString (cfg.themePackages != []) '' + for theme in ${concatStringsSep " " cfg.themePackages}; do + cp -r "$theme"/* "$out/themes.local/" + done + ''} + ''; + + in { + + ###### interface + + options = { + + services.tt-rss = { + + enable = mkEnableOption "tt-rss"; + + root = mkOption { + type = types.path; + default = "/var/lib/tt-rss"; + description = '' + Root of the application. + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = '' + User account under which both the update daemon and the web-application run. + ''; + }; + + pool = mkOption { + type = types.str; + default = "${poolName}"; + description = '' + Name of existing phpfpm pool that is used to run web-application. + If not specified a pool will be created automatically with + default values. + ''; + }; + + virtualHost = mkOption { + type = types.nullOr types.str; + default = "tt-rss"; + description = '' + Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost. + ''; + }; + + database = { + type = mkOption { + type = types.enum ["pgsql" "mysql"]; + default = "pgsql"; + description = '' + Database to store feeds. Supported are pgsql and mysql. + ''; + }; + + host = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Host of the database. Leave null to use Unix domain socket. + ''; + }; + + name = mkOption { + type = types.str; + default = "tt_rss"; + description = '' + Name of the existing database. + ''; + }; + + user = mkOption { + type = types.str; + default = "tt_rss"; + description = '' + The database user. The user must exist and has access to + the specified database. + ''; + }; + + password = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The database user's password. + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The database user's password. + ''; + }; + + port = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + The database's port. If not set, the default ports will be provided (5432 + and 3306 for pgsql and mysql respectively). + ''; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + auth = { + autoCreate = mkOption { + type = types.bool; + default = true; + description = '' + Allow authentication modules to auto-create users in tt-rss internal + database when authenticated successfully. + ''; + }; + + autoLogin = mkOption { + type = types.bool; + default = true; + description = '' + Automatically login user on remote or other kind of externally supplied + authentication, otherwise redirect to login form as normal. + If set to true, users won't be able to set application language + and settings profile. + ''; + }; + }; + + pubSubHubbub = { + hub = mkOption { + type = types.str; + default = ""; + description = '' + URL to a PubSubHubbub-compatible hub server. If defined, "Published + articles" generated feed would automatically become PUSH-enabled. + ''; + }; + + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable client PubSubHubbub support in tt-rss. When disabled, tt-rss + won't try to subscribe to PUSH feed updates. + ''; + }; + }; + + sphinx = { + server = mkOption { + type = types.str; + default = "localhost:9312"; + description = '' + Hostname:port combination for the Sphinx server. + ''; + }; + + index = mkOption { + type = types.listOf types.str; + default = ["ttrss" "delta"]; + description = '' + Index names in Sphinx configuration. Example configuration + files are available on tt-rss wiki. + ''; + }; + }; + + registration = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Allow users to register themselves. Please be aware that allowing + random people to access your tt-rss installation is a security risk + and potentially might lead to data loss or server exploit. Disabled + by default. + ''; + }; + + notifyAddress = mkOption { + type = types.str; + default = ""; + description = '' + Email address to send new user notifications to. + ''; + }; + + maxUsers = mkOption { + type = types.int; + default = 0; + description = '' + Maximum amount of users which will be allowed to register on this + system. 0 - no limit. + ''; + }; + }; + + email = { + server = mkOption { + type = types.str; + default = ""; + example = "localhost:25"; + description = '' + Hostname:port combination to send outgoing mail. Blank - use system + MTA. + ''; + }; + + login = mkOption { + type = types.str; + default = ""; + description = '' + SMTP authentication login used when sending outgoing mail. + ''; + }; + + password = mkOption { + type = types.str; + default = ""; + description = '' + SMTP authentication password used when sending outgoing mail. + ''; + }; + + security = mkOption { + type = types.enum ["" "ssl" "tls"]; + default = ""; + description = '' + Used to select a secure SMTP connection. Allowed values: ssl, tls, + or empty. + ''; + }; + + fromName = mkOption { + type = types.str; + default = "Tiny Tiny RSS"; + description = '' + Name for sending outgoing mail. This applies to password reset + notifications, digest emails and any other mail. + ''; + }; + + fromAddress = mkOption { + type = types.str; + default = ""; + description = '' + Address for sending outgoing mail. This applies to password reset + notifications, digest emails and any other mail. + ''; + }; + + digestSubject = mkOption { + type = types.str; + default = "[tt-rss] New headlines for last 24 hours"; + description = '' + Subject line for email digests. + ''; + }; + }; + + sessionCookieLifetime = mkOption { + type = types.int; + default = 86400; + description = '' + Default lifetime of a session (e.g. login) cookie. In seconds, + 0 means cookie will be deleted when browser closes. + ''; + }; + + selfUrlPath = mkOption { + type = types.str; + description = '' + Full URL of your tt-rss installation. This should be set to the + location of tt-rss directory, e.g. http://example.org/tt-rss/ + You need to set this option correctly otherwise several features + including PUSH, bookmarklets and browser integration will not work properly. + ''; + example = "http://localhost"; + }; + + feedCryptKey = mkOption { + type = types.str; + default = ""; + description = '' + Key used for encryption of passwords for password-protected feeds + in the database. A string of 24 random characters. If left blank, encryption + is not used. Requires mcrypt functions. + Warning: changing this key will make your stored feed passwords impossible + to decrypt. + ''; + }; + + singleUserMode = mkOption { + type = types.bool; + default = false; + + description = '' + Operate in single user mode, disables all functionality related to + multiple users and authentication. Enabling this assumes you have + your tt-rss directory protected by other means (e.g. http auth). + ''; + }; + + simpleUpdateMode = mkOption { + type = types.bool; + default = false; + description = '' + Enables fallback update mode where tt-rss tries to update feeds in + background while tt-rss is open in your browser. + If you don't have a lot of feeds and don't want to or can't run + background processes while not running tt-rss, this method is generally + viable to keep your feeds up to date. + Still, there are more robust (and recommended) updating methods + available, you can read about them here: http://tt-rss.org/wiki/UpdatingFeeds + ''; + }; + + forceArticlePurge = mkOption { + type = types.int; + default = 0; + description = '' + When this option is not 0, users ability to control feed purging + intervals is disabled and all articles (which are not starred) + older than this amount of days are purged. + ''; + }; + + enableGZipOutput = mkOption { + type = types.bool; + default = true; + description = '' + Selectively gzip output to improve wire performance. This requires + PHP Zlib extension on the server. + Enabling this can break tt-rss in several httpd/php configurations, + if you experience weird errors and tt-rss failing to start, blank pages + after login, or content encoding errors, disable it. + ''; + }; + + plugins = mkOption { + type = types.listOf types.str; + default = ["auth_internal" "note"]; + description = '' + List of plugins to load automatically for all users. + System plugins have to be specified here. Please enable at least one + authentication plugin here (auth_*). + Users may enable other user plugins from Preferences/Plugins but may not + disable plugins specified in this list. + Disabling auth_internal in this list would automatically disable + reset password link on the login form. + ''; + }; + + pluginPackages = mkOption { + type = types.listOf types.package; + default = []; + description = '' + List of plugins to install. The list elements are expected to + be derivations. All elements in this derivation are automatically + copied to the <literal>plugins.local</literal> directory. + ''; + }; + + themePackages = mkOption { + type = types.listOf types.package; + default = []; + description = '' + List of themes to install. The list elements are expected to + be derivations. All elements in this derivation are automatically + copied to the <literal>themes.local</literal> directory. + ''; + }; + + logDestination = mkOption { + type = types.enum ["" "sql" "syslog"]; + default = "sql"; + description = '' + Log destination to use. Possible values: sql (uses internal logging + you can read in Preferences -> System), syslog - logs to system log. + Setting this to blank uses PHP logging (usually to http server + error.log). + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Additional lines to append to <literal>config.php</literal>. + ''; + }; + }; + }; + + imports = [ + (mkRemovedOptionModule ["services" "tt-rss" "checkForUpdates"] '' + This option was removed because setting this to true will cause TT-RSS + to be unable to start if an automatic update of the code in + services.tt-rss.root leads to a database schema upgrade that is not + supported by the code active in the Nix store. + '') + ]; + + ###### implementation + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = cfg.database.password != null -> cfg.database.passwordFile == null; + message = "Cannot set both password and passwordFile"; + } + ]; + + services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") { + ${poolName} = { + inherit (cfg) user; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0600"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 10; + "pm.min_spare_servers" = 5; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = 1; + }; + }; + }; + + # NOTE: No configuration is done if not using virtual host + services.nginx = mkIf (cfg.virtualHost != null) { + enable = true; + virtualHosts = { + ${cfg.virtualHost} = { + root = "${cfg.root}/www"; + + locations."/" = { + index = "index.php"; + }; + + locations."^~ /feed-icons" = { + root = "${cfg.root}"; + }; + + locations."~ \\.php$" = { + extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket}; + fastcgi_index index.php; + ''; + }; + }; + }; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.root}' 0555 ${cfg.user} tt_rss - -" + "d '${cfg.root}/lock' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/upload' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/images' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/cache/export' 0755 ${cfg.user} tt_rss - -" + "d '${cfg.root}/feed-icons' 0755 ${cfg.user} tt_rss - -" + "L+ '${cfg.root}/www' - - - - ${servedRoot}" + ]; + + systemd.services = { + phpfpm-tt-rss = mkIf (cfg.pool == "${poolName}") { + restartTriggers = [ servedRoot ]; + }; + + tt-rss = { + description = "Tiny Tiny RSS feeds update daemon"; + + preStart = let + callSql = e: + if cfg.database.type == "pgsql" then '' + ${optionalString (cfg.database.password != null) "PGPASSWORD=${cfg.database.password}"} \ + ${optionalString (cfg.database.passwordFile != null) "PGPASSWORD=$(cat ${cfg.database.passwordFile})"} \ + ${config.services.postgresql.package}/bin/psql \ + -U ${cfg.database.user} \ + ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} --port ${toString dbPort}"} \ + -c '${e}' \ + ${cfg.database.name}'' + + else if cfg.database.type == "mysql" then '' + echo '${e}' | ${config.services.mysql.package}/bin/mysql \ + -u ${cfg.database.user} \ + ${optionalString (cfg.database.password != null) "-p${cfg.database.password}"} \ + ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} -P ${toString dbPort}"} \ + ${cfg.database.name}'' + + else ""; + + in (optionalString (cfg.database.type == "pgsql") '' + exists=$(${callSql "select count(*) > 0 from pg_tables where tableowner = user"} \ + | tail -n+3 | head -n-2 | sed -e 's/[ \n\t]*//') + + if [ "$exists" == 'f' ]; then + ${callSql "\\i ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"} + else + echo 'The database contains some data. Leaving it as it is.' + fi; + '') + + + (optionalString (cfg.database.type == "mysql") '' + exists=$(${callSql "select count(*) > 0 from information_schema.tables where table_schema = schema()"} \ + | tail -n+2 | sed -e 's/[ \n\t]*//') + + if [ "$exists" == '0' ]; then + ${callSql "\\. ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"} + else + echo 'The database contains some data. Leaving it as it is.' + fi; + ''); + + serviceConfig = { + User = "${cfg.user}"; + Group = "tt_rss"; + ExecStart = "${pkgs.php}/bin/php ${cfg.root}/www/update.php --daemon --quiet"; + Restart = "on-failure"; + RestartSec = "60"; + SyslogIdentifier = "tt-rss"; + }; + + wantedBy = [ "multi-user.target" ]; + requires = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + after = [ "network.target" ] ++ optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service"; + }; + }; + + services.mysql = mkIf mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.user; + ensurePermissions = { + "${cfg.database.name}.*" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + services.postgresql = mkIf pgsqlLocal { + enable = mkDefault true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + users.users.tt_rss = optionalAttrs (cfg.user == "tt_rss") { + description = "tt-rss service user"; + isSystemUser = true; + group = "tt_rss"; + }; + + users.groups.tt_rss = {}; + }; +} diff --git a/nixos/modules/services/web-apps/vikunja.nix b/nixos/modules/services/web-apps/vikunja.nix new file mode 100644 index 00000000000..7575e96ca81 --- /dev/null +++ b/nixos/modules/services/web-apps/vikunja.nix @@ -0,0 +1,145 @@ +{ pkgs, lib, config, ... }: + +with lib; + +let + cfg = config.services.vikunja; + format = pkgs.formats.yaml {}; + configFile = format.generate "config.yaml" cfg.settings; + useMysql = cfg.database.type == "mysql"; + usePostgresql = cfg.database.type == "postgres"; +in { + options.services.vikunja = with lib; { + enable = mkEnableOption "vikunja service"; + package-api = mkOption { + default = pkgs.vikunja-api; + type = types.package; + defaultText = literalExpression "pkgs.vikunja-api"; + description = "vikunja-api derivation to use."; + }; + package-frontend = mkOption { + default = pkgs.vikunja-frontend; + type = types.package; + defaultText = literalExpression "pkgs.vikunja-frontend"; + description = "vikunja-frontend derivation to use."; + }; + environmentFiles = mkOption { + type = types.listOf types.path; + default = [ ]; + description = '' + List of environment files set in the vikunja systemd service. + For example passwords should be set in one of these files. + ''; + }; + setupNginx = mkOption { + type = types.bool; + default = config.services.nginx.enable; + defaultText = literalExpression "config.services.nginx.enable"; + description = '' + Whether to setup NGINX. + Further nginx configuration can be done by changing + <option>services.nginx.virtualHosts.<frontendHostname></option>. + This does not enable TLS or ACME by default. To enable this, set the + <option>services.nginx.virtualHosts.<frontendHostname>.enableACME</option> to + <literal>true</literal> and if appropriate do the same for + <option>services.nginx.virtualHosts.<frontendHostname>.forceSSL</option>. + ''; + }; + frontendScheme = mkOption { + type = types.enum [ "http" "https" ]; + description = '' + Whether the site is available via http or https. + This does not configure https or ACME in nginx! + ''; + }; + frontendHostname = mkOption { + type = types.str; + description = "The Hostname under which the frontend is running."; + }; + + settings = mkOption { + type = format.type; + default = {}; + description = '' + Vikunja configuration. Refer to + <link xlink:href="https://vikunja.io/docs/config-options/"/> + for details on supported values. + ''; + }; + database = { + type = mkOption { + type = types.enum [ "sqlite" "mysql" "postgres" ]; + example = "postgres"; + default = "sqlite"; + description = "Database engine to use."; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address. Can also be a socket."; + }; + user = mkOption { + type = types.str; + default = "vikunja"; + description = "Database user."; + }; + database = mkOption { + type = types.str; + default = "vikunja"; + description = "Database name."; + }; + path = mkOption { + type = types.str; + default = "/var/lib/vikunja/vikunja.db"; + description = "Path to the sqlite3 database file."; + }; + }; + }; + config = lib.mkIf cfg.enable { + services.vikunja.settings = { + database = { + inherit (cfg.database) type host user database path; + }; + service = { + frontendurl = "${cfg.frontendScheme}://${cfg.frontendHostname}/"; + }; + files = { + basepath = "/var/lib/vikunja/files"; + }; + }; + + systemd.services.vikunja-api = { + description = "vikunja-api"; + after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.package-api ]; + restartTriggers = [ configFile ]; + + serviceConfig = { + Type = "simple"; + DynamicUser = true; + StateDirectory = "vikunja"; + ExecStart = "${cfg.package-api}/bin/vikunja"; + Restart = "always"; + EnvironmentFile = cfg.environmentFiles; + }; + }; + + services.nginx.virtualHosts."${cfg.frontendHostname}" = mkIf cfg.setupNginx { + locations = { + "/" = { + root = cfg.package-frontend; + tryFiles = "try_files $uri $uri/ /"; + }; + "~* ^/(api|dav|\\.well-known)/" = { + proxyPass = "http://localhost:3456"; + extraConfig = '' + client_max_body_size 20M; + ''; + }; + }; + }; + + environment.etc."vikunja/config.yaml".source = configFile; + }; +} diff --git a/nixos/modules/services/web-apps/virtlyst.nix b/nixos/modules/services/web-apps/virtlyst.nix new file mode 100644 index 00000000000..37bdbb0e3b4 --- /dev/null +++ b/nixos/modules/services/web-apps/virtlyst.nix @@ -0,0 +1,73 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.virtlyst; + stateDir = "/var/lib/virtlyst"; + + ini = pkgs.writeText "virtlyst-config.ini" '' + [wsgi] + master = true + threads = auto + http-socket = ${cfg.httpSocket} + application = ${pkgs.virtlyst}/lib/libVirtlyst.so + chdir2 = ${stateDir} + static-map = /static=${pkgs.virtlyst}/root/static + + [Cutelyst] + production = true + DatabasePath = virtlyst.sqlite + TemplatePath = ${pkgs.virtlyst}/root/src + + [Rules] + cutelyst.* = true + virtlyst.* = true + ''; + +in + +{ + + options.services.virtlyst = { + enable = mkEnableOption "Virtlyst libvirt web interface"; + + adminPassword = mkOption { + type = types.str; + description = '' + Initial admin password with which the database will be seeded. + ''; + }; + + httpSocket = mkOption { + type = types.str; + default = "localhost:3000"; + description = '' + IP and/or port to which to bind the http socket. + ''; + }; + }; + + config = mkIf cfg.enable { + users.users.virtlyst = { + home = stateDir; + createHome = true; + group = mkIf config.virtualisation.libvirtd.enable "libvirtd"; + isSystemUser = true; + }; + + systemd.services.virtlyst = { + wantedBy = [ "multi-user.target" ]; + environment = { + VIRTLYST_ADMIN_PASSWORD = cfg.adminPassword; + }; + serviceConfig = { + ExecStart = "${pkgs.cutelyst}/bin/cutelyst-wsgi2 --ini ${ini}"; + User = "virtlyst"; + WorkingDirectory = stateDir; + }; + }; + }; + +} diff --git a/nixos/modules/services/web-apps/whitebophir.nix b/nixos/modules/services/web-apps/whitebophir.nix new file mode 100644 index 00000000000..f9db6fe379b --- /dev/null +++ b/nixos/modules/services/web-apps/whitebophir.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.whitebophir; +in { + options = { + services.whitebophir = { + enable = mkEnableOption "whitebophir, an online collaborative whiteboard server (persistent state will be maintained under <filename>/var/lib/whitebophir</filename>)"; + + package = mkOption { + default = pkgs.whitebophir; + defaultText = literalExpression "pkgs.whitebophir"; + type = types.package; + description = "Whitebophir package to use."; + }; + + listenAddress = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Address to listen on (use 0.0.0.0 to allow access from any address)."; + }; + + port = mkOption { + type = types.port; + default = 5001; + description = "Port to bind to."; + }; + }; + }; + + config = mkIf cfg.enable { + systemd.services.whitebophir = { + description = "Whitebophir Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + environment = { + PORT = toString cfg.port; + HOST = toString cfg.listenAddress; + WBO_HISTORY_DIR = "/var/lib/whitebophir"; + }; + + serviceConfig = { + DynamicUser = true; + ExecStart = "${cfg.package}/bin/whitebophir"; + Restart = "always"; + StateDirectory = "whitebophir"; + }; + }; + }; +} diff --git a/nixos/modules/services/web-apps/wiki-js.nix b/nixos/modules/services/web-apps/wiki-js.nix new file mode 100644 index 00000000000..1a6259dffee --- /dev/null +++ b/nixos/modules/services/web-apps/wiki-js.nix @@ -0,0 +1,139 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.wiki-js; + + format = pkgs.formats.json { }; + + configFile = format.generate "wiki-js.yml" cfg.settings; +in { + options.services.wiki-js = { + enable = mkEnableOption "wiki-js"; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/root/wiki-js.env"; + description = '' + Environment fiel to inject e.g. secrets into the configuration. + ''; + }; + + stateDirectoryName = mkOption { + default = "wiki-js"; + type = types.str; + description = '' + Name of the directory in <filename>/var/lib</filename>. + ''; + }; + + settings = mkOption { + default = {}; + type = types.submodule { + freeformType = format.type; + options = { + port = mkOption { + type = types.port; + default = 3000; + description = '' + TCP port the process should listen to. + ''; + }; + + bindIP = mkOption { + default = "0.0.0.0"; + type = types.str; + description = '' + IPs the service should listen to. + ''; + }; + + db = { + type = mkOption { + default = "postgres"; + type = types.enum [ "postgres" "mysql" "mariadb" "mssql" ]; + description = '' + Database driver to use for persistence. Please note that <literal>sqlite</literal> + is currently not supported as the build process for it is currently not implemented + in <package>pkgs.wiki-js</package> and it's not recommended by upstream for + production use. + ''; + }; + host = mkOption { + type = types.str; + example = "/run/postgresql"; + description = '' + Hostname or socket-path to connect to. + ''; + }; + db = mkOption { + default = "wiki"; + type = types.str; + description = '' + Name of the database to use. + ''; + }; + }; + + logLevel = mkOption { + default = "info"; + type = types.enum [ "error" "warn" "info" "verbose" "debug" "silly" ]; + description = '' + Define how much detail is supposed to be logged at runtime. + ''; + }; + + offline = mkEnableOption "offline mode" // { + description = '' + Disable latest file updates and enable + <link xlink:href="https://docs.requarks.io/install/sideload">sideloading</link>. + ''; + }; + }; + }; + description = '' + Settings to configure <package>wiki-js</package>. This directly + corresponds to <link xlink:href="https://docs.requarks.io/install/config">the upstream + configuration options</link>. + + Secrets can be injected via the environment by + <itemizedlist> + <listitem><para>specifying <xref linkend="opt-services.wiki-js.environmentFile" /> + to contain secrets</para></listitem> + <listitem><para>and setting sensitive values to <literal>$(ENVIRONMENT_VAR)</literal> + with this value defined in the environment-file.</para></listitem> + </itemizedlist> + ''; + }; + }; + + config = mkIf cfg.enable { + services.wiki-js.settings.dataPath = "/var/lib/${cfg.stateDirectoryName}"; + systemd.services.wiki-js = { + description = "A modern and powerful wiki app built on Node.js"; + documentation = [ "https://docs.requarks.io/" ]; + wantedBy = [ "multi-user.target" ]; + + path = with pkgs; [ coreutils ]; + preStart = '' + ln -sf ${configFile} /var/lib/${cfg.stateDirectoryName}/config.yml + ln -sf ${pkgs.wiki-js}/server /var/lib/${cfg.stateDirectoryName} + ln -sf ${pkgs.wiki-js}/assets /var/lib/${cfg.stateDirectoryName} + ln -sf ${pkgs.wiki-js}/package.json /var/lib/${cfg.stateDirectoryName}/package.json + ''; + + serviceConfig = { + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; + StateDirectory = cfg.stateDirectoryName; + WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}"; + DynamicUser = true; + PrivateTmp = true; + ExecStart = "${pkgs.nodejs}/bin/node ${pkgs.wiki-js}/server"; + }; + }; + }; + + meta.maintainers = with maintainers; [ ma27 ]; +} diff --git a/nixos/modules/services/web-apps/wordpress.nix b/nixos/modules/services/web-apps/wordpress.nix new file mode 100644 index 00000000000..59471a739cb --- /dev/null +++ b/nixos/modules/services/web-apps/wordpress.nix @@ -0,0 +1,480 @@ +{ config, pkgs, lib, ... }: + +with lib; + +let + cfg = config.services.wordpress; + eachSite = cfg.sites; + user = "wordpress"; + webserver = config.services.${cfg.webserver}; + stateDir = hostName: "/var/lib/wordpress/${hostName}"; + + pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { + pname = "wordpress-${hostName}"; + version = src.version; + src = cfg.package; + + installPhase = '' + mkdir -p $out + cp -r * $out/ + + # symlink the wordpress config + ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php + # symlink uploads directory + ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads + + # https://github.com/NixOS/nixpkgs/pull/53399 + # + # Symlinking works for most plugins and themes, but Avada, for instance, fails to + # understand the symlink, causing its file path stripping to fail. This results in + # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js + # Since hard linking directories is not allowed, copying is the next best thing. + + # copy additional plugin(s) and theme(s) + ${concatMapStringsSep "\n" (theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${theme.name}") cfg.themes} + ${concatMapStringsSep "\n" (plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${plugin.name}") cfg.plugins} + ''; + }; + + wpConfig = hostName: cfg: pkgs.writeText "wp-config-${hostName}.php" '' + <?php + define('DB_NAME', '${cfg.database.name}'); + define('DB_HOST', '${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}'); + define('DB_USER', '${cfg.database.user}'); + ${optionalString (cfg.database.passwordFile != null) "define('DB_PASSWORD', file_get_contents('${cfg.database.passwordFile}'));"} + define('DB_CHARSET', 'utf8'); + $table_prefix = '${cfg.database.tablePrefix}'; + + require_once('${stateDir hostName}/secret-keys.php'); + + # wordpress is installed onto a read-only file system + define('DISALLOW_FILE_EDIT', true); + define('AUTOMATIC_UPDATER_DISABLED', true); + + ${cfg.extraConfig} + + if ( !defined('ABSPATH') ) + define('ABSPATH', dirname(__FILE__) . '/'); + + require_once(ABSPATH . 'wp-settings.php'); + ?> + ''; + + secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ]; + secretsScript = hostStateDir: '' + # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839 + grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php" + if ! test -e "${hostStateDir}/secret-keys.php"; then + umask 0177 + echo "<?php" >> "${hostStateDir}/secret-keys.php" + ${concatMapStringsSep "\n" (var: '' + echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php" + '') secretsVars} + echo "?>" >> "${hostStateDir}/secret-keys.php" + chmod 440 "${hostStateDir}/secret-keys.php" + fi + ''; + + siteOpts = { lib, name, ... }: + { + options = { + package = mkOption { + type = types.package; + default = pkgs.wordpress; + defaultText = literalExpression "pkgs.wordpress"; + description = "Which WordPress package to use."; + }; + + uploadsDir = mkOption { + type = types.path; + default = "/var/lib/wordpress/${name}/uploads"; + description = '' + This directory is used for uploads of pictures. The directory passed here is automatically + created and permissions adjusted as required. + ''; + }; + + plugins = mkOption { + type = types.listOf types.path; + default = []; + description = '' + List of path(s) to respective plugin(s) which are copied from the 'plugins' directory. + <note><para>These plugins need to be packaged before use, see example.</para></note> + ''; + example = literalExpression '' + let + # Wordpress plugin 'embed-pdf-viewer' installation example + embedPdfViewerPlugin = pkgs.stdenv.mkDerivation { + name = "embed-pdf-viewer-plugin"; + # Download the theme from the wordpress site + src = pkgs.fetchurl { + url = "https://downloads.wordpress.org/plugin/embed-pdf-viewer.2.0.3.zip"; + sha256 = "1rhba5h5fjlhy8p05zf0p14c9iagfh96y91r36ni0rmk6y891lyd"; + }; + # We need unzip to build this package + nativeBuildInputs = [ pkgs.unzip ]; + # Installing simply means copying all files to the output directory + installPhase = "mkdir -p $out; cp -R * $out/"; + }; + # And then pass this theme to the themes list like this: + in [ embedPdfViewerPlugin ] + ''; + }; + + themes = mkOption { + type = types.listOf types.path; + default = []; + description = '' + List of path(s) to respective theme(s) which are copied from the 'theme' directory. + <note><para>These themes need to be packaged before use, see example.</para></note> + ''; + example = literalExpression '' + let + # Let's package the responsive theme + responsiveTheme = pkgs.stdenv.mkDerivation { + name = "responsive-theme"; + # Download the theme from the wordpress site + src = pkgs.fetchurl { + url = "https://downloads.wordpress.org/theme/responsive.3.14.zip"; + sha256 = "0rjwm811f4aa4q43r77zxlpklyb85q08f9c8ns2akcarrvj5ydx3"; + }; + # We need unzip to build this package + nativeBuildInputs = [ pkgs.unzip ]; + # Installing simply means copying all files to the output directory + installPhase = "mkdir -p $out; cp -R * $out/"; + }; + # And then pass this theme to the themes list like this: + in [ responsiveTheme ] + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "wordpress"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "wordpress"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/wordpress-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + tablePrefix = mkOption { + type = types.str; + default = "wp_"; + description = '' + The $table_prefix is the value placed in the front of your database tables. + Change the value if you want to use something other than wp_ for your database + prefix. Typically this is changed if you are installing multiple WordPress blogs + in the same database. + + See <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php#table_prefix'/>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = null; + defaultText = literalExpression "/run/mysqld/mysqld.sock"; + description = "Path to the unix socket file to use for authentication."; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the WordPress PHP pool. See the documentation on <literal>php-fpm.conf</literal> + for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Any additional text to be appended to the wp-config.php + configuration file. This is a PHP script. For configuration + settings, see <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php'/>. + ''; + example = '' + define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds + ''; + }; + }; + + config.virtualHost.hostName = mkDefault name; + }; +in +{ + # interface + options = { + services.wordpress = { + + sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = "Specification of one or more WordPress sites to serve"; + }; + + webserver = mkOption { + type = types.enum [ "httpd" "nginx" "caddy" ]; + default = "httpd"; + description = '' + Whether to use apache2 or nginx for virtual host management. + + Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.<name></literal>. + See <xref linkend="opt-services.nginx.virtualHosts"/> for further information. + + Further apache2 configuration can be done by adapting <literal>services.httpd.virtualHosts.<name></literal>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + }; + }; + + # implementation + config = mkIf (eachSite != {}) (mkMerge [{ + + assertions = + (mapAttrsToList (hostName: cfg: + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; + }) eachSite) ++ + (mapAttrsToList (hostName: cfg: + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.''; + }) eachSite); + + + services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; + ensureUsers = mapAttrsToList (hostName: cfg: + { name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + } + ) eachSite; + }; + + services.phpfpm.pools = mapAttrs' (hostName: cfg: ( + nameValuePair "wordpress-${hostName}" { + inherit user; + group = webserver.group; + settings = { + "listen.owner" = webserver.user; + "listen.group" = webserver.group; + } // cfg.poolConfig; + } + )) eachSite; + + } + + (mkIf (cfg.webserver == "httpd") { + services.httpd = { + enable = true; + extraModules = [ "proxy_fcgi" ]; + virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${pkg hostName cfg}/share/wordpress"; + extraConfig = '' + <Directory "${pkg hostName cfg}/share/wordpress"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/" + </If> + </FilesMatch> + + # standard wordpress .htaccess contents + <IfModule mod_rewrite.c> + RewriteEngine On + RewriteBase / + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.php [L] + </IfModule> + + DirectoryIndex index.php + Require all granted + Options +FollowSymLinks -Indexes + </Directory> + + # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php + <Files wp-config.php> + Require all denied + </Files> + ''; + } ]) eachSite; + }; + }) + + { + systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [ + "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -" + "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" + "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -" + ]) eachSite); + + systemd.services = mkMerge [ + (mapAttrs' (hostName: cfg: ( + nameValuePair "wordpress-init-${hostName}" { + wantedBy = [ "multi-user.target" ]; + before = [ "phpfpm-wordpress-${hostName}.service" ]; + after = optional cfg.database.createLocally "mysql.service"; + script = secretsScript (stateDir hostName); + + serviceConfig = { + Type = "oneshot"; + User = user; + Group = webserver.group; + }; + })) eachSite) + + (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { + httpd.after = [ "mysql.service" ]; + }) + ]; + + users.users.${user} = { + group = webserver.group; + isSystemUser = true; + }; + } + + (mkIf (cfg.webserver == "nginx") { + services.nginx = { + enable = true; + virtualHosts = mapAttrs (hostName: cfg: { + serverName = mkDefault hostName; + root = "${pkg hostName cfg}/share/wordpress"; + extraConfig = '' + index index.php; + ''; + locations = { + "/" = { + priority = 200; + extraConfig = '' + try_files $uri $uri/ /index.php$is_args$args; + ''; + }; + "~ \\.php$" = { + priority = 500; + extraConfig = '' + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}; + fastcgi_index index.php; + include "${config.services.nginx.package}/conf/fastcgi.conf"; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; + # Mitigate https://httpoxy.org/ vulnerabilities + fastcgi_param HTTP_PROXY ""; + fastcgi_intercept_errors off; + fastcgi_buffer_size 16k; + fastcgi_buffers 4 16k; + fastcgi_connect_timeout 300; + fastcgi_send_timeout 300; + fastcgi_read_timeout 300; + ''; + }; + "~ /\\." = { + priority = 800; + extraConfig = "deny all;"; + }; + "~* /(?:uploads|files)/.*\\.php$" = { + priority = 900; + extraConfig = "deny all;"; + }; + "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = { + priority = 1000; + extraConfig = '' + expires max; + log_not_found off; + ''; + }; + }; + }) eachSite; + }; + }) + + (mkIf (cfg.webserver == "caddy") { + services.caddy = { + enable = true; + virtualHosts = mapAttrs' (hostName: cfg: ( + nameValuePair "http://${hostName}" { + extraConfig = '' + root * /${pkg hostName cfg}/share/wordpress + file_server + + php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket} + + @uploads { + path_regexp path /uploads\/(.*)\.php + } + rewrite @uploads / + + @wp-admin { + path not ^\/wp-admin/* + } + rewrite @wp-admin {path}/index.php?{query} + ''; + } + )) eachSite; + }; + }) + + + ]); +} diff --git a/nixos/modules/services/web-apps/youtrack.nix b/nixos/modules/services/web-apps/youtrack.nix new file mode 100644 index 00000000000..b83265ffeab --- /dev/null +++ b/nixos/modules/services/web-apps/youtrack.nix @@ -0,0 +1,181 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.youtrack; + + extraAttr = concatStringsSep " " (mapAttrsToList (k: v: "-D${k}=${v}") (stdParams // cfg.extraParams)); + mergeAttrList = lib.foldl' lib.mergeAttrs {}; + + stdParams = mergeAttrList [ + (optionalAttrs (cfg.baseUrl != null) { + "jetbrains.youtrack.baseUrl" = cfg.baseUrl; + }) + { + "java.aws.headless" = "true"; + "jetbrains.youtrack.disableBrowser" = "true"; + } + ]; +in +{ + options.services.youtrack = { + + enable = mkEnableOption "YouTrack service"; + + address = mkOption { + description = '' + The interface youtrack will listen on. + ''; + default = "127.0.0.1"; + type = types.str; + }; + + baseUrl = mkOption { + description = '' + Base URL for youtrack. Will be auto-detected and stored in database. + ''; + type = types.nullOr types.str; + default = null; + }; + + extraParams = mkOption { + default = {}; + description = '' + Extra parameters to pass to youtrack. See + https://www.jetbrains.com/help/youtrack/standalone/YouTrack-Java-Start-Parameters.html + for more information. + ''; + example = literalExpression '' + { + "jetbrains.youtrack.overrideRootPassword" = "tortuga"; + } + ''; + type = types.attrsOf types.str; + }; + + package = mkOption { + description = '' + Package to use. + ''; + type = types.package; + default = pkgs.youtrack; + defaultText = literalExpression "pkgs.youtrack"; + }; + + port = mkOption { + description = '' + The port youtrack will listen on. + ''; + default = 8080; + type = types.int; + }; + + statePath = mkOption { + description = '' + Where to keep the youtrack database. + ''; + type = types.path; + default = "/var/lib/youtrack"; + }; + + virtualHost = mkOption { + description = '' + Name of the nginx virtual host to use and setup. + If null, do not setup anything. + ''; + default = null; + type = types.nullOr types.str; + }; + + jvmOpts = mkOption { + description = '' + Extra options to pass to the JVM. + See https://www.jetbrains.com/help/youtrack/standalone/Configure-JVM-Options.html + for more information. + ''; + type = types.separatedString " "; + example = "-XX:MetaspaceSize=250m"; + default = ""; + }; + + maxMemory = mkOption { + description = '' + Maximum Java heap size + ''; + type = types.str; + default = "1g"; + }; + + maxMetaspaceSize = mkOption { + description = '' + Maximum java Metaspace memory. + ''; + type = types.str; + default = "350m"; + }; + }; + + config = mkIf cfg.enable { + + systemd.services.youtrack = { + environment.HOME = cfg.statePath; + environment.YOUTRACK_JVM_OPTS = "${extraAttr}"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ unixtools.hostname ]; + serviceConfig = { + Type = "simple"; + User = "youtrack"; + Group = "youtrack"; + Restart = "on-failure"; + ExecStart = ''${cfg.package}/bin/youtrack --J-Xmx${cfg.maxMemory} --J-XX:MaxMetaspaceSize=${cfg.maxMetaspaceSize} ${cfg.jvmOpts} ${cfg.address}:${toString cfg.port}''; + }; + }; + + users.users.youtrack = { + description = "Youtrack service user"; + isSystemUser = true; + home = cfg.statePath; + createHome = true; + group = "youtrack"; + }; + + users.groups.youtrack = {}; + + services.nginx = mkIf (cfg.virtualHost != null) { + upstreams.youtrack.servers."${cfg.address}:${toString cfg.port}" = {}; + virtualHosts.${cfg.virtualHost}.locations = { + "/" = { + proxyPass = "http://youtrack"; + extraConfig = '' + client_max_body_size 10m; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + "/api/eventSourceBus" = { + proxyPass = "http://youtrack"; + extraConfig = '' + proxy_cache off; + proxy_buffering off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_set_header Connection ""; + chunked_transfer_encoding off; + client_max_body_size 10m; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + }; + }; + + }; +} diff --git a/nixos/modules/services/web-apps/zabbix.nix b/nixos/modules/services/web-apps/zabbix.nix new file mode 100644 index 00000000000..538dac0d5be --- /dev/null +++ b/nixos/modules/services/web-apps/zabbix.nix @@ -0,0 +1,238 @@ +{ config, lib, options, pkgs, ... }: + +let + + inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types; + inherit (lib) literalExpression mapAttrs optionalString versionAtLeast; + + cfg = config.services.zabbixWeb; + opt = options.services.zabbixWeb; + fpm = config.services.phpfpm.pools.zabbix; + + user = "zabbix"; + group = "zabbix"; + stateDir = "/var/lib/zabbix"; + + zabbixConfig = pkgs.writeText "zabbix.conf.php" '' + <?php + // Zabbix GUI configuration file. + global $DB; + $DB['TYPE'] = '${ { mysql = "MYSQL"; pgsql = "POSTGRESQL"; oracle = "ORACLE"; }.${cfg.database.type} }'; + $DB['SERVER'] = '${cfg.database.host}'; + $DB['PORT'] = '${toString cfg.database.port}'; + $DB['DATABASE'] = '${cfg.database.name}'; + $DB['USER'] = '${cfg.database.user}'; + # NOTE: file_get_contents adds newline at the end of returned string + $DB['PASSWORD'] = ${if cfg.database.passwordFile != null then "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")" else "''"}; + // Schema name. Used for IBM DB2 and PostgreSQL. + $DB['SCHEMA'] = '''; + $ZBX_SERVER = '${cfg.server.address}'; + $ZBX_SERVER_PORT = '${toString cfg.server.port}'; + $ZBX_SERVER_NAME = '''; + $IMAGE_FORMAT_DEFAULT = IMAGE_FORMAT_PNG; + + ${cfg.extraConfig} + ''; + +in +{ + # interface + + options.services = { + zabbixWeb = { + enable = mkEnableOption "the Zabbix web interface"; + + package = mkOption { + type = types.package; + default = pkgs.zabbix.web; + defaultText = literalExpression "zabbix.web"; + description = "Which Zabbix package to use."; + }; + + server = { + port = mkOption { + type = types.int; + description = "The port of the Zabbix server to connect to."; + default = 10051; + }; + + address = mkOption { + type = types.str; + description = "The IP address or hostname of the Zabbix server to connect to."; + default = "localhost"; + }; + }; + + database = { + type = mkOption { + type = types.enum [ "mysql" "pgsql" "oracle" ]; + example = "mysql"; + default = "pgsql"; + description = "Database engine to use."; + }; + + host = mkOption { + type = types.str; + default = ""; + description = "Database host address."; + }; + + port = mkOption { + type = types.int; + default = + if cfg.database.type == "mysql" then config.services.mysql.port + else if cfg.database.type == "pgsql" then config.services.postgresql.port + else 1521; + defaultText = literalExpression '' + if config.${opt.database.type} == "mysql" then config.${options.services.mysql.port} + else if config.${opt.database.type} == "pgsql" then config.${options.services.postgresql.port} + else 1521 + ''; + description = "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "zabbix"; + description = "Database name."; + }; + + user = mkOption { + type = types.str; + default = "zabbix"; + description = "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/zabbix-dbpassword"; + description = '' + A file containing the password corresponding to + <option>database.user</option>. + ''; + }; + + socket = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/postgresql"; + description = "Path to the unix socket file to use for authentication."; + }; + }; + + virtualHost = mkOption { + type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix); + example = literalExpression '' + { + hostName = "zabbix.example.org"; + adminAddr = "webmaster@example.org"; + forceSSL = true; + enableACME = true; + } + ''; + description = '' + Apache configuration can be done by adapting <literal>services.httpd.virtualHosts.<name></literal>. + See <xref linkend="opt-services.httpd.virtualHosts"/> for further information. + ''; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = '' + Options for the Zabbix PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Additional configuration to be copied verbatim into <filename>zabbix.conf.php</filename>. + ''; + }; + + }; + }; + + # implementation + + config = mkIf cfg.enable { + + services.zabbixWeb.extraConfig = optionalString ((versionAtLeast config.system.stateVersion "20.09") && (versionAtLeast cfg.package.version "5.0.0")) '' + $DB['DOUBLE_IEEE754'] = 'true'; + ''; + + systemd.tmpfiles.rules = [ + "d '${stateDir}' 0750 ${user} ${group} - -" + "d '${stateDir}/session' 0750 ${user} ${config.services.httpd.group} - -" + ]; + + services.phpfpm.pools.zabbix = { + inherit user; + group = config.services.httpd.group; + phpOptions = '' + # https://www.zabbix.com/documentation/current/manual/installation/install + memory_limit = 128M + post_max_size = 16M + upload_max_filesize = 2M + max_execution_time = 300 + max_input_time = 300 + session.auto_start = 0 + mbstring.func_overload = 0 + always_populate_raw_post_data = -1 + # https://bbs.archlinux.org/viewtopic.php?pid=1745214#p1745214 + session.save_path = ${stateDir}/session + '' + optionalString (config.time.timeZone != null) '' + date.timezone = "${config.time.timeZone}" + '' + optionalString (cfg.database.type == "oracle") '' + extension=${pkgs.phpPackages.oci8}/lib/php/extensions/oci8.so + ''; + phpEnv.ZABBIX_CONFIG = "${zabbixConfig}"; + settings = { + "listen.owner" = config.services.httpd.user; + "listen.group" = config.services.httpd.group; + } // cfg.poolConfig; + }; + + services.httpd = { + enable = true; + adminAddr = mkDefault cfg.virtualHost.adminAddr; + extraModules = [ "proxy_fcgi" ]; + virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost { + documentRoot = mkForce "${cfg.package}/share/zabbix"; + extraConfig = '' + <Directory "${cfg.package}/share/zabbix"> + <FilesMatch "\.php$"> + <If "-f %{REQUEST_FILENAME}"> + SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/" + </If> + </FilesMatch> + AllowOverride all + Options -Indexes + DirectoryIndex index.php + </Directory> + ''; + } ]; + }; + + users.users.${user} = mapAttrs (name: mkDefault) { + description = "Zabbix daemon user"; + uid = config.ids.uids.zabbix; + inherit group; + }; + + users.groups.${group} = mapAttrs (name: mkDefault) { + gid = config.ids.gids.zabbix; + }; + + }; +} |