diff --git a/hosts/default.nix b/hosts/default.nix index a2c2241..7e1d5c4 100644 --- a/hosts/default.nix +++ b/hosts/default.nix @@ -61,6 +61,7 @@ self.nixosModules.yule self.nixosModules.docker self.nixosModules.wireguard-client + self.nixosModules.invoiceplane ]; }; diff --git a/hosts/pie/configuration.nix b/hosts/pie/configuration.nix index 885bee4..d8a1aad 100644 --- a/hosts/pie/configuration.nix +++ b/hosts/pie/configuration.nix @@ -23,7 +23,7 @@ in { boot.kernelParams = [ "boot.shell_on_fail=1" - "ip=192.168.178.2::192.168.178.1:255.255.255.0:pie.b12f.io::off" + "ip=192.168.178.2::192.168.178.1:255.255.255.255:pie.b12f.io::off" ]; boot.initrd.network.enable = true; diff --git a/hosts/pie/invoiceplane.nix b/hosts/pie/invoiceplane.nix index 3b5ea57..0e195e8 100644 --- a/hosts/pie/invoiceplane.nix +++ b/hosts/pie/invoiceplane.nix @@ -27,10 +27,23 @@ in { user = "invoiceplane"; name = "invoiceplane"; passwordFile = config.age.secrets."invoiceplane-db-password.age".path; - host = "localhost"; + host = "127.0.0.1"; port = 3306; createLocally = false; }; + + poolConfig = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.max_requests" = 500; + "pm.max_spare_servers" = 4; + "pm.min_spare_servers" = 2; + "pm.start_servers" = 2; + "php_admin_value[error_log]" = "/var/lib/invoiceplane/invoicing.b12f.io/logs/php-error.log"; + "php_admin_flag[display_errors]" = "off"; + "php_admin_flag[log_errors]" = "on"; + "catch_workers_output" = "yes"; + }; }; virtualisation = { diff --git a/modules/default.nix b/modules/default.nix index 61ed19b..6b1e456 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -5,6 +5,7 @@ }: { flake = { nixosModules = rec { + adb = import ./adb; arduino = import ./arduino; audio = import ./audio; bluetooth = import ./bluetooth; @@ -16,7 +17,7 @@ email = import ./email; gaming = import ./gaming; graphical = import ./graphical; - adb = import ./adb; + invoiceplane = import ./invoiceplane; nix = import ./nix; nextcloud = import ./nextcloud; office = import ./office; diff --git a/modules/invoiceplane/default.nix b/modules/invoiceplane/default.nix new file mode 100644 index 0000000..a29a5ca --- /dev/null +++ b/modules/invoiceplane/default.nix @@ -0,0 +1,362 @@ +{ 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 + REMOVE_INDEXPHP=true + ''; + + 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; + + postPhase = '' + # Patch index.php file to load additional config file + substituteInPlace index.php \ + --replace "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__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 (lib.mdDoc "InvoicePlane web application"); + + stateDir = mkOption { + type = types.path; + default = "/var/lib/invoiceplane/${name}"; + description = lib.mdDoc '' + This directory is used for uploads of attachments and cache. + The directory passed here is automatically created and permissions + adjusted as required. + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + + name = mkOption { + type = types.str; + default = "invoiceplane"; + description = lib.mdDoc "Database name."; + }; + + user = mkOption { + type = types.str; + default = "invoiceplane"; + description = lib.mdDoc "Database user."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/invoiceplane-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + invoiceTemplates = mkOption { + type = types.listOf types.path; + default = []; + description = lib.mdDoc '' + List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory. + + ::: {.note} + These templates need to be packaged before use, see example. + ::: + ''; + 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 = lib.mdDoc '' + Options for the InvoicePlane PHP pool. See the documentation on `php-fpm.conf` + 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 = lib.mdDoc '' + InvoicePlane configuration. Refer to + + for details on supported values. + ''; + }; + + cron = { + + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Enable cron service which periodically runs Invoiceplane tasks. + Requires key taken from the administration page. Refer to + + on how to configure it. + ''; + }; + + key = mkOption { + type = types.str; + description = lib.mdDoc "Cron key taken from the administration page."; + }; + + }; + + }; + + }; +in +{ + disabledModules = [ + "services/web-apps/invoiceplane.nix" + ]; + + # interface + options = { + services.invoiceplane = mkOption { + type = types.submodule { + + options.sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = {}; + description = lib.mdDoc "Specification of one or more WordPress sites to serve"; + }; + + options.webserver = mkOption { + type = types.enum [ "caddy" ]; + default = "caddy"; + description = lib.mdDoc '' + Which webserver to use for virtual host management. Currently only + caddy is supported. + ''; + }; + }; + default = {}; + description = lib.mdDoc "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.''; + } + { assertion = cfg.cron.enable -> cfg.cron.key != null; + message = ''services.invoiceplane.sites."${hostName}".cron.key must be set in order to use cron service.''; + } + ]) 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.php81; + 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; + }; + + } + { + + # Cron service implementation + + systemd.timers = mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-cron-${hostName}" (mkIf cfg.cron.enable { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "5m"; + OnUnitActiveSec = "5m"; + Unit = "invoiceplane-cron-${hostName}.service"; + }; + }) + )) eachSite; + + systemd.services = + mapAttrs' (hostName: cfg: ( + nameValuePair "invoiceplane-cron-${hostName}" (mkIf cfg.cron.enable { + serviceConfig = { + Type = "oneshot"; + User = user; + ExecStart = "${pkgs.curl}/bin/curl --header 'Host: ${hostName}' http://localhost/invoices/cron/recur/${cfg.cron.key}"; + }; + }) + )) eachSite; + + } + + (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; + }; + }) + + ]); +}