about summary refs log tree commit diff
path: root/nixos/modules/services
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services')
-rw-r--r--nixos/modules/services/backup/borgbackup.nix10
-rw-r--r--nixos/modules/services/backup/borgmatic.nix3
-rw-r--r--nixos/modules/services/cluster/kubernetes/kubelet.nix8
-rw-r--r--nixos/modules/services/computing/boinc/client.nix2
-rw-r--r--nixos/modules/services/continuous-integration/buildbot/master.nix11
-rw-r--r--nixos/modules/services/continuous-integration/buildbot/worker.nix7
-rw-r--r--nixos/modules/services/development/lorri.nix2
-rw-r--r--nixos/modules/services/hardware/auto-cpufreq.nix21
-rw-r--r--nixos/modules/services/home-automation/esphome.nix136
-rw-r--r--nixos/modules/services/mail/maddy.nix38
-rw-r--r--nixos/modules/services/mail/roundcube.nix2
-rw-r--r--nixos/modules/services/misc/gitit.nix725
-rw-r--r--nixos/modules/services/misc/gpsd.nix46
-rw-r--r--nixos/modules/services/misc/pufferpanel.nix176
-rw-r--r--nixos/modules/services/monitoring/grafana-agent.nix2
-rw-r--r--nixos/modules/services/monitoring/prometheus/exporters/smartctl.nix12
-rw-r--r--nixos/modules/services/network-filesystems/kubo.nix73
-rw-r--r--nixos/modules/services/network-filesystems/openafs/server.nix86
-rw-r--r--nixos/modules/services/networking/ddclient.nix4
-rw-r--r--nixos/modules/services/networking/dhcpd.nix7
-rw-r--r--nixos/modules/services/networking/go-neb.nix3
-rw-r--r--nixos/modules/services/networking/ivpn.nix51
-rw-r--r--nixos/modules/services/networking/peroxide.nix2
-rw-r--r--nixos/modules/services/networking/smokeping.nix11
-rw-r--r--nixos/modules/services/networking/ssh/sshd.nix2
-rw-r--r--nixos/modules/services/networking/wgautomesh.nix161
-rw-r--r--nixos/modules/services/networking/wstunnel.nix4
-rw-r--r--nixos/modules/services/printing/cupsd.nix1
-rw-r--r--nixos/modules/services/search/qdrant.nix1
-rw-r--r--nixos/modules/services/security/fail2ban.nix10
-rw-r--r--nixos/modules/services/security/kanidm.nix102
-rw-r--r--nixos/modules/services/system/cachix-watch-store.nix6
-rw-r--r--nixos/modules/services/video/mediamtx.nix (renamed from nixos/modules/services/video/rtsp-simple-server.nix)32
-rw-r--r--nixos/modules/services/video/v4l2-relayd.nix199
-rw-r--r--nixos/modules/services/web-apps/ihatemoney/default.nix153
-rw-r--r--nixos/modules/services/web-apps/mastodon.nix7
-rw-r--r--nixos/modules/services/web-apps/monica.nix468
-rw-r--r--nixos/modules/services/web-apps/moodle.nix2
-rw-r--r--nixos/modules/services/web-apps/plausible.nix14
-rw-r--r--nixos/modules/services/web-servers/nginx/default.nix2
-rw-r--r--nixos/modules/services/x11/desktop-managers/pantheon.nix2
-rw-r--r--nixos/modules/services/x11/desktop-managers/plasma5.nix3
42 files changed, 1560 insertions, 1047 deletions
diff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix
index bc2d79ac10ac..08a2967e9c7f 100644
--- a/nixos/modules/services/backup/borgbackup.nix
+++ b/nixos/modules/services/backup/borgbackup.nix
@@ -66,6 +66,7 @@ let
       ${mkKeepArgs cfg} \
       ${optionalString (cfg.prune.prefix != null) "--glob-archives ${escapeShellArg "${cfg.prune.prefix}*"}"} \
       $extraPruneArgs
+    borg compact $extraArgs $extraCompactArgs
     ${cfg.postPrune}
   '');
 
@@ -638,6 +639,15 @@ in {
             example = "--save-space";
           };
 
+          extraCompactArgs = mkOption {
+            type = types.str;
+            description = lib.mdDoc ''
+              Additional arguments for {command}`borg compact`.
+              Can also be set at runtime using `$extraCompactArgs`.
+            '';
+            default = "";
+            example = "--cleanup-commits";
+          };
         };
       }
     ));
diff --git a/nixos/modules/services/backup/borgmatic.nix b/nixos/modules/services/backup/borgmatic.nix
index e7cd6ae4bb57..5ee036e68c7b 100644
--- a/nixos/modules/services/backup/borgmatic.nix
+++ b/nixos/modules/services/backup/borgmatic.nix
@@ -72,5 +72,8 @@ in
         cfg.configurations;
 
     systemd.packages = [ pkgs.borgmatic ];
+
+    # Workaround: https://github.com/NixOS/nixpkgs/issues/81138
+    systemd.timers.borgmatic.wantedBy = [ "timers.target" ];
   };
 }
diff --git a/nixos/modules/services/cluster/kubernetes/kubelet.nix b/nixos/modules/services/cluster/kubernetes/kubelet.nix
index 8e935d621be4..eebacb3f3ef3 100644
--- a/nixos/modules/services/cluster/kubernetes/kubelet.nix
+++ b/nixos/modules/services/cluster/kubernetes/kubelet.nix
@@ -63,6 +63,7 @@ in
     (mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "cadvisorPort" ] "")
     (mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "allowPrivileged" ] "")
     (mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "networkPlugin" ] "")
+    (mkRemovedOptionModule [ "services" "kubernetes" "kubelet" "containerRuntime" ] "")
   ];
 
   ###### interface
@@ -134,12 +135,6 @@ in
       };
     };
 
-    containerRuntime = mkOption {
-      description = lib.mdDoc "Which container runtime type to use";
-      type = enum ["docker" "remote"];
-      default = "remote";
-    };
-
     containerRuntimeEndpoint = mkOption {
       description = lib.mdDoc "Endpoint at which to find the container runtime api interface/socket";
       type = str;
@@ -331,7 +326,6 @@ in
             ${optionalString (cfg.tlsKeyFile != null)
               "--tls-private-key-file=${cfg.tlsKeyFile}"} \
             ${optionalString (cfg.verbosity != null) "--v=${toString cfg.verbosity}"} \
-            --container-runtime=${cfg.containerRuntime} \
             --container-runtime-endpoint=${cfg.containerRuntimeEndpoint} \
             --cgroup-driver=systemd \
             ${cfg.extraOpts}
diff --git a/nixos/modules/services/computing/boinc/client.nix b/nixos/modules/services/computing/boinc/client.nix
index 5fb715f4d779..1879fef9666f 100644
--- a/nixos/modules/services/computing/boinc/client.nix
+++ b/nixos/modules/services/computing/boinc/client.nix
@@ -6,7 +6,7 @@ let
   cfg = config.services.boinc;
   allowRemoteGuiRpcFlag = optionalString cfg.allowRemoteGuiRpc "--allow_remote_gui_rpc";
 
-  fhsEnv = pkgs.buildFHSUserEnv {
+  fhsEnv = pkgs.buildFHSEnv {
     name = "boinc-fhs-env";
     targetPkgs = pkgs': [ cfg.package ] ++ cfg.extraEnvPackages;
     runScript = "/bin/boinc_client";
diff --git a/nixos/modules/services/continuous-integration/buildbot/master.nix b/nixos/modules/services/continuous-integration/buildbot/master.nix
index 5666199c4845..595374ea1e5b 100644
--- a/nixos/modules/services/continuous-integration/buildbot/master.nix
+++ b/nixos/modules/services/continuous-integration/buildbot/master.nix
@@ -8,7 +8,8 @@ let
   cfg = config.services.buildbot-master;
   opt = options.services.buildbot-master;
 
-  python = cfg.package.pythonModule;
+  package = pkgs.python3.pkgs.toPythonModule cfg.package;
+  python = package.pythonModule;
 
   escapeStr = escape [ "'" ];
 
@@ -212,10 +213,10 @@ in {
 
       package = mkOption {
         type = types.package;
-        default = pkgs.python3Packages.buildbot-full;
-        defaultText = literalExpression "pkgs.python3Packages.buildbot-full";
+        default = pkgs.buildbot-full;
+        defaultText = literalExpression "pkgs.buildbot-full";
         description = lib.mdDoc "Package to use for buildbot.";
-        example = literalExpression "pkgs.python3Packages.buildbot";
+        example = literalExpression "pkgs.buildbot";
       };
 
       packages = mkOption {
@@ -255,7 +256,7 @@ in {
       after = [ "network-online.target" ];
       wantedBy = [ "multi-user.target" ];
       path = cfg.packages ++ cfg.pythonPackages python.pkgs;
-      environment.PYTHONPATH = "${python.withPackages (self: cfg.pythonPackages self ++ [ cfg.package ])}/${python.sitePackages}";
+      environment.PYTHONPATH = "${python.withPackages (self: cfg.pythonPackages self ++ [ package ])}/${python.sitePackages}";
 
       preStart = ''
         mkdir -vp "${cfg.buildbotDir}"
diff --git a/nixos/modules/services/continuous-integration/buildbot/worker.nix b/nixos/modules/services/continuous-integration/buildbot/worker.nix
index 52c41c4a7584..7e78b8935f81 100644
--- a/nixos/modules/services/continuous-integration/buildbot/worker.nix
+++ b/nixos/modules/services/continuous-integration/buildbot/worker.nix
@@ -8,7 +8,8 @@ let
   cfg = config.services.buildbot-worker;
   opt = options.services.buildbot-worker;
 
-  python = cfg.package.pythonModule;
+  package = pkgs.python3.pkgs.toPythonModule cfg.package;
+  python = package.pythonModule;
 
   tacFile = pkgs.writeText "aur-buildbot-worker.tac" ''
     import os
@@ -129,7 +130,7 @@ in {
 
       package = mkOption {
         type = types.package;
-        default = pkgs.python3Packages.buildbot-worker;
+        default = pkgs.buildbot-worker;
         defaultText = literalExpression "pkgs.python3Packages.buildbot-worker";
         description = lib.mdDoc "Package to use for buildbot worker.";
         example = literalExpression "pkgs.python2Packages.buildbot-worker";
@@ -168,7 +169,7 @@ in {
       after = [ "network.target" "buildbot-master.service" ];
       wantedBy = [ "multi-user.target" ];
       path = cfg.packages;
-      environment.PYTHONPATH = "${python.withPackages (p: [ cfg.package ])}/${python.sitePackages}";
+      environment.PYTHONPATH = "${python.withPackages (p: [ package ])}/${python.sitePackages}";
 
       preStart = ''
         mkdir -vp "${cfg.buildbotDir}/info"
diff --git a/nixos/modules/services/development/lorri.nix b/nixos/modules/services/development/lorri.nix
index 8c64e3d9a560..74f56f5890fc 100644
--- a/nixos/modules/services/development/lorri.nix
+++ b/nixos/modules/services/development/lorri.nix
@@ -50,6 +50,6 @@ in {
       };
     };
 
-    environment.systemPackages = [ cfg.package ];
+    environment.systemPackages = [ cfg.package pkgs.direnv ];
   };
 }
diff --git a/nixos/modules/services/hardware/auto-cpufreq.nix b/nixos/modules/services/hardware/auto-cpufreq.nix
index 9698e72eb31e..df7c01ae54ef 100644
--- a/nixos/modules/services/hardware/auto-cpufreq.nix
+++ b/nixos/modules/services/hardware/auto-cpufreq.nix
@@ -2,10 +2,26 @@
 with lib;
 let
   cfg = config.services.auto-cpufreq;
+  cfgFilename = "auto-cpufreq.conf";
+  cfgFile = format.generate cfgFilename cfg.settings;
+
+  format = pkgs.formats.ini {};
 in {
   options = {
     services.auto-cpufreq = {
       enable = mkEnableOption (lib.mdDoc "auto-cpufreq daemon");
+
+      settings = mkOption {
+        description = lib.mdDoc ''
+          Configuration for `auto-cpufreq`.
+
+          See its [example configuration file] for supported settings.
+          [example configuration file]: https://github.com/AdnanHodzic/auto-cpufreq/blob/master/auto-cpufreq.conf-example
+          '';
+
+        default = {};
+        type = types.submodule { freeformType = format.type; };
+      };
     };
   };
 
@@ -18,6 +34,11 @@ in {
         # Workaround for https://github.com/NixOS/nixpkgs/issues/81138
         wantedBy = [ "multi-user.target" ];
         path = with pkgs; [ bash coreutils ];
+
+        serviceConfig.ExecStart = [
+          ""
+          "${lib.getExe pkgs.auto-cpufreq} --config ${cfgFile}"
+        ];
       };
     };
   };
diff --git a/nixos/modules/services/home-automation/esphome.nix b/nixos/modules/services/home-automation/esphome.nix
new file mode 100644
index 000000000000..d7dbb6f0b90e
--- /dev/null
+++ b/nixos/modules/services/home-automation/esphome.nix
@@ -0,0 +1,136 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib)
+    literalExpression
+    maintainers
+    mkEnableOption
+    mkIf
+    mkOption
+    mdDoc
+    types
+    ;
+
+  cfg = config.services.esphome;
+
+  stateDir = "/var/lib/esphome";
+
+  esphomeParams =
+    if cfg.enableUnixSocket
+    then "--socket /run/esphome/esphome.sock"
+    else "--address ${cfg.address} --port ${toString cfg.port}";
+in
+{
+  meta.maintainers = with maintainers; [ oddlama ];
+
+  options.services.esphome = {
+    enable = mkEnableOption (mdDoc "esphome");
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.esphome;
+      defaultText = literalExpression "pkgs.esphome";
+      description = mdDoc "The package to use for the esphome command.";
+    };
+
+    enableUnixSocket = mkOption {
+      type = types.bool;
+      default = false;
+      description = lib.mdDoc "Listen on a unix socket `/run/esphome/esphome.sock` instead of the TCP port.";
+    };
+
+    address = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = mdDoc "esphome address";
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 6052;
+      description = mdDoc "esphome port";
+    };
+
+    openFirewall = mkOption {
+      default = false;
+      type = types.bool;
+      description = mdDoc "Whether to open the firewall for the specified port.";
+    };
+
+    allowedDevices = mkOption {
+      default = ["char-ttyS" "char-ttyUSB"];
+      example = ["/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"];
+      description = lib.mdDoc ''
+        A list of device nodes to which {command}`esphome` has access to.
+        Refer to DeviceAllow in systemd.resource-control(5) for more information.
+        Beware that if a device is referred to by an absolute path instead of a device category,
+        it will only allow devices that already are plugged in when the service is started.
+      '';
+      type = types.listOf types.str;
+    };
+  };
+
+  config = mkIf cfg.enable {
+    networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall && !cfg.enableUnixSocket) [cfg.port];
+
+    systemd.services.esphome = {
+      description = "ESPHome dashboard";
+      after = ["network.target"];
+      wantedBy = ["multi-user.target"];
+      path = [cfg.package];
+
+      # platformio fails to determine the home directory when using DynamicUser
+      environment.PLATFORMIO_CORE_DIR = "${stateDir}/.platformio";
+
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/esphome dashboard ${esphomeParams} ${stateDir}";
+        DynamicUser = true;
+        User = "esphome";
+        Group = "esphome";
+        WorkingDirectory = stateDir;
+        StateDirectory = "esphome";
+        StateDirectoryMode = "0750";
+        Restart = "on-failure";
+        RuntimeDirectory = mkIf cfg.enableUnixSocket "esphome";
+        RuntimeDirectoryMode = "0750";
+
+        # Hardening
+        CapabilityBoundingSet = "";
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        DevicePolicy = "closed";
+        DeviceAllow = map (d: "${d} rw") cfg.allowedDevices;
+        SupplementaryGroups = ["dialout"];
+        #NoNewPrivileges = true; # Implied by DynamicUser
+        PrivateUsers = true;
+        #PrivateTmp = true; # Implied by DynamicUser
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        ProcSubset = "pid";
+        ProtectSystem = "strict";
+        #RemoveIPC = true; # Implied by DynamicUser
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+          "AF_NETLINK"
+          "AF_UNIX"
+        ];
+        RestrictNamespaces = false; # Required by platformio for chroot
+        RestrictRealtime = true;
+        #RestrictSUIDSGID = true; # Implied by DynamicUser
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [
+          "@system-service"
+          "@mount" # Required by platformio for chroot
+        ];
+        UMask = "0077";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/mail/maddy.nix b/nixos/modules/services/mail/maddy.nix
index 5f3a9b56292d..d0b525bcb002 100644
--- a/nixos/modules/services/mail/maddy.nix
+++ b/nixos/modules/services/mail/maddy.nix
@@ -228,8 +228,8 @@ in {
         default = [];
         description = lib.mdDoc ''
           List of IMAP accounts which get automatically created. Note that for
-          a complete setup, user credentials for these accounts are required too
-          and can be created using the command `maddyctl creds`.
+          a complete setup, user credentials for these accounts are required
+          and can be created using the `ensureCredentials` option.
           This option does not delete accounts which are not (anymore) listed.
         '';
         example = [
@@ -238,6 +238,33 @@ in {
         ];
       };
 
+      ensureCredentials = mkOption {
+        default = {};
+        description = lib.mdDoc ''
+          List of user accounts which get automatically created if they don't
+          exist yet. Note that for a complete setup, corresponding mail boxes
+          have to get created using the `ensureAccounts` option.
+          This option does not delete accounts which are not (anymore) listed.
+        '';
+        example = {
+          "user1@localhost".passwordFile = /secrets/user1-localhost;
+          "user2@localhost".passwordFile = /secrets/user2-localhost;
+        };
+        type = types.attrsOf (types.submodule {
+          options = {
+            passwordFile = mkOption {
+              type = types.path;
+              example = "/path/to/file";
+              default = null;
+              description = lib.mdDoc ''
+                Specifies the path to a file containing the
+                clear text password for the user.
+              '';
+            };
+          };
+        });
+      };
+
     };
   };
 
@@ -265,6 +292,13 @@ in {
                 fi
               '') cfg.ensureAccounts}
             ''}
+            ${optionalString (cfg.ensureCredentials != {}) ''
+              ${concatStringsSep "\n" (mapAttrsToList (name: cfg: ''
+                if ! ${pkgs.maddy}/bin/maddyctl creds list | grep "${name}"; then
+                  ${pkgs.maddy}/bin/maddyctl creds create --password $(cat ${escapeShellArg cfg.passwordFile}) ${name}
+                fi
+              '') cfg.ensureCredentials)}
+            ''}
           '';
           serviceConfig = {
             Type = "oneshot";
diff --git a/nixos/modules/services/mail/roundcube.nix b/nixos/modules/services/mail/roundcube.nix
index 7b6d82219298..3aaec145930d 100644
--- a/nixos/modules/services/mail/roundcube.nix
+++ b/nixos/modules/services/mail/roundcube.nix
@@ -7,7 +7,7 @@ let
   fpm = config.services.phpfpm.pools.roundcube;
   localDB = cfg.database.host == "localhost";
   user = cfg.database.username;
-  phpWithPspell = pkgs.php80.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled);
+  phpWithPspell = pkgs.php81.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled);
 in
 {
   options.services.roundcube = {
diff --git a/nixos/modules/services/misc/gitit.nix b/nixos/modules/services/misc/gitit.nix
deleted file mode 100644
index 0fafa76b5487..000000000000
--- a/nixos/modules/services/misc/gitit.nix
+++ /dev/null
@@ -1,725 +0,0 @@
-{ config, lib, pkgs, ... }:
-
-with lib;
-
-let
-
-  cfg = config.services.gitit;
-
-  homeDir = "/var/lib/gitit";
-
-  toYesNo = b: if b then "yes" else "no";
-
-  gititShared = with cfg.haskellPackages; gitit + "/share/" + ghc.targetPrefix + ghc.haskellCompilerName + "/" + gitit.pname + "-" + gitit.version;
-
-  gititWithPkgs = hsPkgs: extras: hsPkgs.ghcWithPackages (self: with self; [ gitit ] ++ (extras self));
-
-  gititSh = hsPkgs: extras: with pkgs; let
-    env = gititWithPkgs hsPkgs extras;
-  in writeScript "gitit" ''
-    #!${runtimeShell}
-    cd $HOME
-    export NIX_GHC="${env}/bin/ghc"
-    export NIX_GHCPKG="${env}/bin/ghc-pkg"
-    export NIX_GHC_DOCDIR="${env}/share/doc/ghc/html"
-    export NIX_GHC_LIBDIR=$( $NIX_GHC --print-libdir )
-    ${env}/bin/gitit -f ${configFile}
-  '';
-
-  gititOptions = {
-
-      enable = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc "Enable the gitit service.";
-      };
-
-      haskellPackages = mkOption {
-        default = pkgs.haskellPackages;
-        defaultText = literalExpression "pkgs.haskellPackages";
-        example = literalExpression "pkgs.haskell.packages.ghc784";
-        description = lib.mdDoc "haskellPackages used to build gitit and plugins.";
-      };
-
-      extraPackages = mkOption {
-        type = types.functionTo (types.listOf types.package);
-        default = self: [];
-        example = literalExpression ''
-          haskellPackages: [
-            haskellPackages.wreq
-          ]
-        '';
-        description = lib.mdDoc ''
-          Extra packages available to ghc when running gitit. The
-          value must be a function which receives the attrset defined
-          in {var}`haskellPackages` as the sole argument.
-        '';
-      };
-
-      address = mkOption {
-        type = types.str;
-        default = "0.0.0.0";
-        description = lib.mdDoc "IP address on which the web server will listen.";
-      };
-
-      port = mkOption {
-        type = types.int;
-        default = 5001;
-        description = lib.mdDoc "Port on which the web server will run.";
-      };
-
-      wikiTitle = mkOption {
-        type = types.str;
-        default = "Gitit!";
-        description = lib.mdDoc "The wiki title.";
-      };
-
-      repositoryType = mkOption {
-        type = types.enum ["git" "darcs" "mercurial"];
-        default = "git";
-        description = lib.mdDoc "Specifies the type of repository used for wiki content.";
-      };
-
-      repositoryPath = mkOption {
-        type = types.path;
-        default = homeDir + "/wiki";
-        description = lib.mdDoc ''
-          Specifies the path of the repository directory. If it does not
-          exist, gitit will create it on startup.
-        '';
-      };
-
-      requireAuthentication = mkOption {
-        type = types.enum [ "none" "modify" "read" ];
-        default = "modify";
-        description = lib.mdDoc ''
-          If 'none', login is never required, and pages can be edited
-          anonymously.  If 'modify', login is required to modify the wiki
-          (edit, add, delete pages, upload files).  If 'read', login is
-          required to see any wiki pages.
-        '';
-      };
-
-      authenticationMethod = mkOption {
-        type = types.enum [ "form" "http" "generic" "github" ];
-        default = "form";
-        description = lib.mdDoc ''
-          'form' means that users will be logged in and registered using forms
-          in the gitit web interface.  'http' means that gitit will assume that
-          HTTP authentication is in place and take the logged in username from
-          the "Authorization" field of the HTTP request header (in addition,
-          the login/logout and registration links will be suppressed).
-          'generic' means that gitit will assume that some form of
-          authentication is in place that directly sets REMOTE_USER to the name
-          of the authenticated user (e.g. mod_auth_cas on apache).  'rpx' means
-          that gitit will attempt to log in through https://rpxnow.com.  This
-          requires that 'rpx-domain', 'rpx-key', and 'base-url' be set below,
-          and that 'curl' be in the system path.
-        '';
-      };
-
-      userFile = mkOption {
-        type = types.path;
-        default = homeDir + "/gitit-users";
-        description = lib.mdDoc ''
-          Specifies the path of the file containing user login information.  If
-          it does not exist, gitit will create it (with an empty user list).
-          This file is not used if 'http' is selected for
-          authentication-method.
-        '';
-      };
-
-      sessionTimeout = mkOption {
-        type = types.int;
-        default = 60;
-        description = lib.mdDoc ''
-          Number of minutes of inactivity before a session expires.
-        '';
-      };
-
-      staticDir = mkOption {
-        type = types.path;
-        default = gititShared + "/data/static";
-        description = lib.mdDoc ''
-          Specifies the path of the static directory (containing javascript,
-          css, and images).  If it does not exist, gitit will create it and
-          populate it with required scripts, stylesheets, and images.
-        '';
-      };
-
-      defaultPageType = mkOption {
-        type = types.enum [ "markdown" "rst" "latex" "html" "markdown+lhs" "rst+lhs" "latex+lhs" ];
-        default = "markdown";
-        description = lib.mdDoc ''
-          Specifies the type of markup used to interpret pages in the wiki.
-          Possible values are markdown, rst, latex, html, markdown+lhs,
-          rst+lhs, and latex+lhs. (the +lhs variants treat the input as
-          literate Haskell. See pandoc's documentation for more details.) If
-          Markdown is selected, pandoc's syntax extensions (for footnotes,
-          delimited code blocks, etc.) will be enabled. Note that pandoc's
-          restructuredtext parser is not complete, so some pages may not be
-          rendered correctly if rst is selected. The same goes for latex and
-          html.
-        '';
-      };
-
-      math = mkOption {
-        type = types.enum [ "mathml" "raw" "mathjax" "jsmath" "google" ];
-        default = "mathml";
-        description = lib.mdDoc ''
-          Specifies how LaTeX math is to be displayed.  Possible values are
-          mathml, raw, mathjax, jsmath, and google.  If mathml is selected,
-          gitit will convert LaTeX math to MathML and link in a script,
-          MathMLinHTML.js, that allows the MathML to be seen in Gecko browsers,
-          IE + mathplayer, and Opera. In other browsers you may get a jumble of
-          characters.  If raw is selected, the LaTeX math will be displayed as
-          raw LaTeX math.  If mathjax is selected, gitit will link to the
-          remote mathjax script.  If jsMath is selected, gitit will link to the
-          script /js/jsMath/easy/load.js, and will assume that jsMath has been
-          installed into the js/jsMath directory.  This is the most portable
-          solution. If google is selected, the google chart API is called to
-          render the formula as an image. This requires a connection to google,
-          and might raise a technical or a privacy problem.
-        '';
-      };
-
-      mathJaxScript = mkOption {
-        type = types.str;
-        default = "https://d3eoax9i5htok0.cloudfront.net/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML";
-        description = lib.mdDoc ''
-          Specifies the path to MathJax rendering script.  You might want to
-          use your own MathJax script to render formulas without Internet
-          connection or if you want to use some special LaTeX packages.  Note:
-          path specified there cannot be an absolute path to a script on your
-          hdd, instead you should run your (local if you wish) HTTP server
-          which will serve the MathJax.js script. You can easily (in four lines
-          of code) serve MathJax.js using
-          http://happstack.com/docs/crashcourse/FileServing.html Do not forget
-          the "http://" prefix (e.g. http://localhost:1234/MathJax.js).
-        '';
-      };
-
-      showLhsBirdTracks = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          Specifies whether to show Haskell code blocks in "bird style", with
-          "> " at the beginning of each line.
-        '';
-      };
-
-      templatesDir = mkOption {
-        type = types.path;
-        default = gititShared + "/data/templates";
-        description = lib.mdDoc ''
-          Specifies the path of the directory containing page templates.  If it
-          does not exist, gitit will create it with default templates.  Users
-          may wish to edit the templates to customize the appearance of their
-          wiki. The template files are HStringTemplate templates.  Variables to
-          be interpolated appear between $\'s. Literal $\'s must be
-          backslash-escaped.
-        '';
-      };
-
-      logFile = mkOption {
-        type = types.path;
-        default = homeDir + "/gitit.log";
-        description = lib.mdDoc ''
-          Specifies the path of gitit's log file.  If it does not exist, gitit
-          will create it. The log is in Apache combined log format.
-        '';
-      };
-
-      logLevel = mkOption {
-        type = types.enum [ "DEBUG" "INFO" "NOTICE" "WARNING" "ERROR" "CRITICAL" "ALERT" "EMERGENCY" ];
-        default = "ERROR";
-        description = lib.mdDoc ''
-          Determines how much information is logged.  Possible values (from
-          most to least verbose) are DEBUG, INFO, NOTICE, WARNING, ERROR,
-          CRITICAL, ALERT, EMERGENCY.
-        '';
-      };
-
-      frontPage = mkOption {
-        type = types.str;
-        default = "Front Page";
-        description = lib.mdDoc ''
-          Specifies which wiki page is to be used as the wiki's front page.
-          Gitit creates a default front page on startup, if one does not exist
-          already.
-        '';
-      };
-
-      noDelete = mkOption {
-        type = types.str;
-        default = "Front Page, Help";
-        description = lib.mdDoc ''
-          Specifies pages that cannot be deleted through the web interface.
-          (They can still be deleted directly using git or darcs.) A
-          comma-separated list of page names.  Leave blank to allow every page
-          to be deleted.
-        '';
-      };
-
-      noEdit = mkOption {
-        type = types.str;
-        default = "Help";
-        description = lib.mdDoc ''
-          Specifies pages that cannot be edited through the web interface.
-          Leave blank to allow every page to be edited.
-        '';
-      };
-
-      defaultSummary = mkOption {
-        type = types.str;
-        default = "";
-        description = lib.mdDoc ''
-          Specifies text to be used in the change description if the author
-          leaves the "description" field blank.  If default-summary is blank
-          (the default), the author will be required to fill in the description
-          field.
-        '';
-      };
-
-      tableOfContents = mkOption {
-        type = types.bool;
-        default = true;
-        description = lib.mdDoc ''
-          Specifies whether to print a tables of contents (with links to
-          sections) on each wiki page.
-        '';
-      };
-
-      plugins = mkOption {
-        type = with types; listOf str;
-        default = [ (gititShared + "/plugins/Dot.hs") ];
-        description = lib.mdDoc ''
-          Specifies a list of plugins to load. Plugins may be specified either
-          by their path or by their module name. If the plugin name starts
-          with Gitit.Plugin., gitit will assume that the plugin is an installed
-          module and will not try to find a source file.
-        '';
-      };
-
-      useCache = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          Specifies whether to cache rendered pages.  Note that if use-feed is
-          selected, feeds will be cached regardless of the value of use-cache.
-        '';
-      };
-
-      cacheDir = mkOption {
-        type = types.path;
-        default = homeDir + "/cache";
-        description = lib.mdDoc "Path where rendered pages will be cached.";
-      };
-
-      maxUploadSize = mkOption {
-        type = types.str;
-        default = "1000K";
-        description = lib.mdDoc ''
-          Specifies an upper limit on the size (in bytes) of files uploaded
-          through the wiki's web interface.  To disable uploads, set this to
-          0K.  This will result in the uploads link disappearing and the
-          _upload url becoming inactive.
-        '';
-      };
-
-      maxPageSize = mkOption {
-        type = types.str;
-        default = "1000K";
-        description = lib.mdDoc "Specifies an upper limit on the size (in bytes) of pages.";
-      };
-
-      debugMode = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc "Causes debug information to be logged while gitit is running.";
-      };
-
-      compressResponses = mkOption {
-        type = types.bool;
-        default = true;
-        description = lib.mdDoc "Specifies whether HTTP responses should be compressed.";
-      };
-
-      mimeTypesFile = mkOption {
-        type = types.path;
-        default = "/etc/mime/types.info";
-        description = lib.mdDoc ''
-          Specifies the path of a file containing mime type mappings.  Each
-          line of the file should contain two fields, separated by whitespace.
-          The first field is the mime type, the second is a file extension.
-          For example:
-          ```
-          video/x-ms-wmx  wmx
-          ```
-          If the file is not found, some simple defaults will be used.
-        '';
-      };
-
-      useReCaptcha = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          If true, causes gitit to use the reCAPTCHA service
-          (http://recaptcha.net) to prevent bots from creating accounts.
-        '';
-      };
-
-      reCaptchaPrivateKey = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc ''
-          Specifies the private key for the reCAPTCHA service.  To get
-          these, you need to create an account at http://recaptcha.net.
-        '';
-      };
-
-      reCaptchaPublicKey = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc ''
-          Specifies the public key for the reCAPTCHA service.  To get
-          these, you need to create an account at http://recaptcha.net.
-        '';
-      };
-
-      accessQuestion = mkOption {
-        type = types.str;
-        default = "What is the code given to you by Ms. X?";
-        description = lib.mdDoc ''
-          Specifies a question that users must answer when they attempt to
-          create an account
-        '';
-      };
-
-      accessQuestionAnswers = mkOption {
-        type = types.str;
-        default = "RED DOG, red dog";
-        description = lib.mdDoc ''
-          Specifies a question that users must answer when they attempt to
-          create an account, along with a comma-separated list of acceptable
-          answers.  This can be used to institute a rudimentary password for
-          signing up as a user on the wiki, or as an alternative to reCAPTCHA.
-          Example:
-          access-question:  What is the code given to you by Ms. X?
-          access-question-answers:  RED DOG, red dog
-        '';
-      };
-
-      rpxDomain = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc ''
-          Specifies the domain and key of your RPX account.  The domain is just
-          the prefix of the complete RPX domain, so if your full domain is
-          'https://foo.rpxnow.com/', use 'foo' as the value of rpx-domain.
-        '';
-      };
-
-      rpxKey = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "RPX account access key.";
-      };
-
-      mailCommand = mkOption {
-        type = types.str;
-        default = "sendmail %s";
-        description = lib.mdDoc ''
-          Specifies the command to use to send notification emails.  '%s' will
-          be replaced by the destination email address.  The body of the
-          message will be read from stdin.  If this field is left blank,
-          password reset will not be offered.
-        '';
-      };
-
-      resetPasswordMessage = mkOption {
-        type = types.lines;
-        default = ''
-          > From: gitit@$hostname$
-          > To: $useremail$
-          > Subject: Wiki password reset
-          >
-          > Hello $username$,
-          >
-          > To reset your password, please follow the link below:
-          > http://$hostname$:$port$$resetlink$
-          >
-          > Regards
-        '';
-        description = lib.mdDoc ''
-          Gives the text of the message that will be sent to the user should
-          she want to reset her password, or change other registration info.
-          The lines must be indented, and must begin with '>'.  The initial
-          spaces and '> ' will be stripped off.  $username$ will be replaced by
-          the user's username, $useremail$ by her email address, $hostname$ by
-          the hostname on which the wiki is running (as returned by the
-          hostname system call), $port$ by the port on which the wiki is
-          running, and $resetlink$ by the relative path of a reset link derived
-          from the user's existing hashed password. If your gitit wiki is being
-          proxied to a location other than the root path of $port$, you should
-          change the link to reflect this: for example, to
-          http://$hostname$/path/to/wiki$resetlink$ or
-          http://gitit.$hostname$$resetlink$
-        '';
-      };
-
-      useFeed = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          Specifies whether an ATOM feed should be enabled (for the site and
-          for individual pages).
-        '';
-      };
-
-      baseUrl = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc ''
-          The base URL of the wiki, to be used in constructing feed IDs and RPX
-          token_urls.  Set this if useFeed is false or authentication-method
-          is 'rpx'.
-        '';
-      };
-
-      absoluteUrls = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          Make wikilinks absolute with respect to the base-url.  So, for
-          example, in a wiki served at the base URL '/wiki', on a page
-          Sub/Page, the wikilink `[Cactus]()` will produce a link to
-          '/wiki/Cactus' if absoluteUrls is true, and a relative link to
-          'Cactus' (referring to '/wiki/Sub/Cactus') if absolute-urls is 'no'.
-        '';
-      };
-
-      feedDays = mkOption {
-        type = types.int;
-        default = 14;
-        description = lib.mdDoc "Number of days to be included in feeds.";
-      };
-
-      feedRefreshTime = mkOption {
-        type = types.int;
-        default = 60;
-        description = lib.mdDoc "Number of minutes to cache feeds before refreshing.";
-      };
-
-      pdfExport = mkOption {
-        type = types.bool;
-        default = false;
-        description = lib.mdDoc ''
-          If true, PDF will appear in export options. PDF will be created using
-          pdflatex, which must be installed and in the path. Note that PDF
-          exports create significant additional server load.
-        '';
-      };
-
-      pandocUserData = mkOption {
-        type = with types; nullOr path;
-        default = null;
-        description = lib.mdDoc ''
-          If a directory is specified, this will be searched for pandoc
-          customizations. These can include a templates/ directory for custom
-          templates for various export formats, an S5 directory for custom S5
-          styles, and a reference.odt for ODT exports. If no directory is
-          specified, $HOME/.pandoc will be searched. See pandoc's README for
-          more information.
-        '';
-      };
-
-      xssSanitize = mkOption {
-        type = types.bool;
-        default = true;
-        description = lib.mdDoc ''
-          If true, all HTML (including that produced by pandoc) is filtered
-          through xss-sanitize.  Set to no only if you trust all of your users.
-        '';
-      };
-
-      oauthClientId = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "OAuth client ID";
-      };
-
-      oauthClientSecret = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "OAuth client secret";
-      };
-
-      oauthCallback = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "OAuth callback URL";
-      };
-
-      oauthAuthorizeEndpoint = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "OAuth authorize endpoint";
-      };
-
-      oauthAccessTokenEndpoint = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "OAuth access token endpoint";
-      };
-
-      githubOrg = mkOption {
-        type = with types; nullOr str;
-        default = null;
-        description = lib.mdDoc "Github organization";
-      };
-  };
-
-  configFile = pkgs.writeText "gitit.conf" ''
-    address: ${cfg.address}
-    port: ${toString cfg.port}
-    wiki-title: ${cfg.wikiTitle}
-    repository-type: ${cfg.repositoryType}
-    repository-path: ${cfg.repositoryPath}
-    require-authentication: ${cfg.requireAuthentication}
-    authentication-method: ${cfg.authenticationMethod}
-    user-file: ${cfg.userFile}
-    session-timeout: ${toString cfg.sessionTimeout}
-    static-dir: ${cfg.staticDir}
-    default-page-type: ${cfg.defaultPageType}
-    math: ${cfg.math}
-    mathjax-script: ${cfg.mathJaxScript}
-    show-lhs-bird-tracks: ${toYesNo cfg.showLhsBirdTracks}
-    templates-dir: ${cfg.templatesDir}
-    log-file: ${cfg.logFile}
-    log-level: ${cfg.logLevel}
-    front-page: ${cfg.frontPage}
-    no-delete: ${cfg.noDelete}
-    no-edit: ${cfg.noEdit}
-    default-summary: ${cfg.defaultSummary}
-    table-of-contents: ${toYesNo cfg.tableOfContents}
-    plugins: ${concatStringsSep "," cfg.plugins}
-    use-cache: ${toYesNo cfg.useCache}
-    cache-dir: ${cfg.cacheDir}
-    max-upload-size: ${cfg.maxUploadSize}
-    max-page-size: ${cfg.maxPageSize}
-    debug-mode: ${toYesNo cfg.debugMode}
-    compress-responses: ${toYesNo cfg.compressResponses}
-    mime-types-file: ${cfg.mimeTypesFile}
-    use-recaptcha: ${toYesNo cfg.useReCaptcha}
-    recaptcha-private-key: ${toString cfg.reCaptchaPrivateKey}
-    recaptcha-public-key: ${toString cfg.reCaptchaPublicKey}
-    access-question: ${cfg.accessQuestion}
-    access-question-answers: ${cfg.accessQuestionAnswers}
-    rpx-domain: ${toString cfg.rpxDomain}
-    rpx-key: ${toString cfg.rpxKey}
-    mail-command: ${cfg.mailCommand}
-    reset-password-message: ${cfg.resetPasswordMessage}
-    use-feed: ${toYesNo cfg.useFeed}
-    base-url: ${toString cfg.baseUrl}
-    absolute-urls: ${toYesNo cfg.absoluteUrls}
-    feed-days: ${toString cfg.feedDays}
-    feed-refresh-time: ${toString cfg.feedRefreshTime}
-    pdf-export: ${toYesNo cfg.pdfExport}
-    pandoc-user-data: ${toString cfg.pandocUserData}
-    xss-sanitize: ${toYesNo cfg.xssSanitize}
-
-    [Github]
-    oauthclientid: ${toString cfg.oauthClientId}
-    oauthclientsecret: ${toString cfg.oauthClientSecret}
-    oauthcallback: ${toString cfg.oauthCallback}
-    oauthauthorizeendpoint: ${toString cfg.oauthAuthorizeEndpoint}
-    oauthaccesstokenendpoint: ${toString cfg.oauthAccessTokenEndpoint}
-    github-org: ${toString cfg.githubOrg}
-  '';
-
-in
-
-{
-
-  options.services.gitit = gititOptions;
-
-  config = mkIf cfg.enable {
-
-    users.users.gitit = {
-      group = config.users.groups.gitit.name;
-      description = "Gitit user";
-      home = homeDir;
-      createHome = true;
-      uid = config.ids.uids.gitit;
-    };
-
-    users.groups.gitit.gid = config.ids.gids.gitit;
-
-    systemd.services.gitit = let
-      uid = toString config.ids.uids.gitit;
-      gid = toString config.ids.gids.gitit;
-    in {
-      description = "Git and Pandoc Powered Wiki";
-      after = [ "network.target" ];
-      wantedBy = [ "multi-user.target" ];
-      path = with pkgs; [ curl ]
-             ++ optional cfg.pdfExport texlive.combined.scheme-basic
-             ++ optional (cfg.repositoryType == "darcs") darcs
-             ++ optional (cfg.repositoryType == "mercurial") mercurial
-             ++ optional (cfg.repositoryType == "git") git;
-
-      preStart = let
-        gm = "gitit@${config.networking.hostName}";
-      in
-      with cfg; ''
-        chown ${uid}:${gid} -R ${homeDir}
-        for dir in ${repositoryPath} ${staticDir} ${templatesDir} ${cacheDir}
-        do
-          if [ ! -d $dir ]
-          then
-            mkdir -p $dir
-            find $dir -type d -exec chmod 0750 {} +
-            find $dir -type f -exec chmod 0640 {} +
-          fi
-        done
-        cd ${repositoryPath}
-        ${
-          if repositoryType == "darcs" then
-          ''
-          if [ ! -d _darcs ]
-          then
-            darcs initialize
-            echo "${gm}" > _darcs/prefs/email
-          ''
-          else if repositoryType == "mercurial" then
-          ''
-          if [ ! -d .hg ]
-          then
-            hg init
-            cat >> .hg/hgrc <<NAMED
-[ui]
-username = gitit ${gm}
-NAMED
-          ''
-          else
-          ''
-          if [ ! -d  .git ]
-          then
-            git init
-            git config user.email "${gm}"
-            git config user.name "gitit"
-          ''}
-          chown ${uid}:${gid} -R ${repositoryPath}
-          fi
-        cd -
-      '';
-
-      serviceConfig = {
-        User = config.users.users.gitit.name;
-        Group = config.users.groups.gitit.name;
-        ExecStart = with cfg; gititSh haskellPackages extraPackages;
-      };
-    };
-  };
-}
diff --git a/nixos/modules/services/misc/gpsd.nix b/nixos/modules/services/misc/gpsd.nix
index 9b03b6f9662e..ce0f9bb3ba28 100644
--- a/nixos/modules/services/misc/gpsd.nix
+++ b/nixos/modules/services/misc/gpsd.nix
@@ -1,4 +1,4 @@
-{ config, lib, pkgs, ... }:
+{ config, lib, pkgs, utils, ... }:
 
 with lib;
 
@@ -8,12 +8,15 @@ let
   gid = config.ids.gids.gpsd;
   cfg = config.services.gpsd;
 
-in
-
-{
+in {
 
   ###### interface
 
+  imports = [
+    (lib.mkRemovedOptionModule [ "services" "gpsd" "device" ]
+      "Use `services.gpsd.devices` instead.")
+  ];
+
   options = {
 
     services.gpsd = {
@@ -26,13 +29,17 @@ in
         '';
       };
 
-      device = mkOption {
-        type = types.str;
-        default = "/dev/ttyUSB0";
+      devices = mkOption {
+        type = types.listOf types.str;
+        default = [ "/dev/ttyUSB0" ];
         description = lib.mdDoc ''
-          A device may be a local serial device for GPS input, or a URL of the form:
-               `[{dgpsip|ntrip}://][user:passwd@]host[:port][/stream]`
-          in which case it specifies an input source for DGPS or ntrip data.
+          List of devices that `gpsd` should subscribe to.
+
+          A device may be a local serial device for GPS input, or a
+          URL of the form:
+          `[{dgpsip|ntrip}://][user:passwd@]host[:port][/stream]` in
+          which case it specifies an input source for DGPS or ntrip
+          data.
         '';
       };
 
@@ -89,17 +96,16 @@ in
 
   };
 
-
   ###### implementation
 
   config = mkIf cfg.enable {
 
-    users.users.gpsd =
-      { inherit uid;
-        group = "gpsd";
-        description = "gpsd daemon user";
-        home = "/var/empty";
-      };
+    users.users.gpsd = {
+      inherit uid;
+      group = "gpsd";
+      description = "gpsd daemon user";
+      home = "/var/empty";
+    };
 
     users.groups.gpsd = { inherit gid; };
 
@@ -109,13 +115,15 @@ in
       after = [ "network.target" ];
       serviceConfig = {
         Type = "forking";
-        ExecStart = ''
+        ExecStart = let
+          devices = utils.escapeSystemdExecArgs cfg.devices;
+        in ''
           ${pkgs.gpsd}/sbin/gpsd -D "${toString cfg.debugLevel}"  \
             -S "${toString cfg.port}"                             \
             ${optionalString cfg.readonly "-b"}                   \
             ${optionalString cfg.nowait "-n"}                     \
             ${optionalString cfg.listenany "-G"}                  \
-            "${cfg.device}"
+            ${devices}
         '';
       };
     };
diff --git a/nixos/modules/services/misc/pufferpanel.nix b/nixos/modules/services/misc/pufferpanel.nix
new file mode 100644
index 000000000000..78ec35646907
--- /dev/null
+++ b/nixos/modules/services/misc/pufferpanel.nix
@@ -0,0 +1,176 @@
+{ config, pkgs, lib, ... }:
+let
+  cfg = config.services.pufferpanel;
+in
+{
+  options.services.pufferpanel = {
+    enable = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = lib.mdDoc ''
+        Whether to enable PufferPanel game management server.
+
+        Note that [PufferPanel templates] and binaries downloaded by PufferPanel
+        expect [FHS environment]. It is possible to set {option}`package` option
+        to use PufferPanel wrapper with FHS environment. For example, to use
+        `Download Game from Steam` and `Download Java` template operations:
+        ```Nix
+        { lib, pkgs, ... }: {
+          services.pufferpanel = {
+            enable = true;
+            extraPackages = with pkgs; [ bash curl gawk gnutar gzip ];
+            package = pkgs.buildFHSUserEnv {
+              name = "pufferpanel-fhs";
+              runScript = lib.getExe pkgs.pufferpanel;
+              targetPkgs = pkgs': with pkgs'; [ icu openssl zlib ];
+            };
+          };
+        }
+        ```
+
+        [PufferPanel templates]: https://github.com/PufferPanel/templates
+        [FHS environment]: https://wikipedia.org/wiki/Filesystem_Hierarchy_Standard
+      '';
+    };
+
+    package = lib.mkPackageOptionMD pkgs "pufferpanel" { };
+
+    extraGroups = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      default = [ ];
+      example = [ "podman" ];
+      description = lib.mdDoc ''
+        Additional groups for the systemd service.
+      '';
+    };
+
+    extraPackages = lib.mkOption {
+      type = lib.types.listOf lib.types.package;
+      default = [ ];
+      example = lib.literalExpression "[ pkgs.jre ]";
+      description = lib.mdDoc ''
+        Packages to add to the PATH environment variable. Both the {file}`bin`
+        and {file}`sbin` subdirectories of each package are added.
+      '';
+    };
+
+    environment = lib.mkOption {
+      type = lib.types.attrsOf lib.types.str;
+      default = { };
+      example = lib.literalExpression ''
+        {
+          PUFFER_WEB_HOST = ":8080";
+          PUFFER_DAEMON_SFTP_HOST = ":5657";
+          PUFFER_DAEMON_CONSOLE_BUFFER = "1000";
+          PUFFER_DAEMON_CONSOLE_FORWARD = "true";
+          PUFFER_PANEL_REGISTRATIONENABLED = "false";
+        }
+      '';
+      description = lib.mdDoc ''
+        Environment variables to set for the service. Secrets should be
+        specified using {option}`environmentFile`.
+
+        Refer to the [PufferPanel source code][] for the list of available
+        configuration options. Variable name is an upper-cased configuration
+        entry name with underscores instead of dots, prefixed with `PUFFER_`.
+        For example, `panel.settings.companyName` entry can be set using
+        {env}`PUFFER_PANEL_SETTINGS_COMPANYNAME`.
+
+        When running with panel enabled (configured with `PUFFER_PANEL_ENABLE`
+        environment variable), it is recommended disable registration using
+        `PUFFER_PANEL_REGISTRATIONENABLED` environment variable (registration is
+        enabled by default). To create the initial administrator user, run
+        {command}`pufferpanel --workDir /var/lib/pufferpanel user add --admin`.
+
+        Some options override corresponding settings set via web interface (e.g.
+        `PUFFER_PANEL_REGISTRATIONENABLED`). Those options can be temporarily
+        toggled or set in settings but do not persist between restarts.
+
+        [PufferPanel source code]: https://github.com/PufferPanel/PufferPanel/blob/master/config/entries.go
+      '';
+    };
+
+    environmentFile = lib.mkOption {
+      type = lib.types.nullOr lib.types.path;
+      default = null;
+      description = lib.mdDoc ''
+        File to load environment variables from. Loaded variables override
+        values set in {option}`environment`.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.pufferpanel = {
+      description = "PufferPanel game management server";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network.target" ];
+
+      path = cfg.extraPackages;
+      environment = cfg.environment;
+
+      # Note that we export environment variables for service directories if the
+      # value is not set. An empty environment variable is considered to be set.
+      # E.g.
+      #   export PUFFER_LOGS=${PUFFER_LOGS-$LOGS_DIRECTORY}
+      # would set PUFFER_LOGS to $LOGS_DIRECTORY if PUFFER_LOGS environment
+      # variable is not defined.
+      script = ''
+        ${lib.concatLines (lib.mapAttrsToList (name: value: ''
+          export ${name}="''${${name}-${value}}"
+        '') {
+          PUFFER_LOGS = "$LOGS_DIRECTORY";
+          PUFFER_DAEMON_DATA_CACHE = "$CACHE_DIRECTORY";
+          PUFFER_DAEMON_DATA_SERVERS = "$STATE_DIRECTORY/servers";
+          PUFFER_DAEMON_DATA_BINARIES = "$STATE_DIRECTORY/binaries";
+        })}
+        exec ${lib.getExe cfg.package} run --workDir "$STATE_DIRECTORY"
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        UMask = "0077";
+
+        SupplementaryGroups = cfg.extraGroups;
+
+        StateDirectory = "pufferpanel";
+        StateDirectoryMode = "0700";
+        CacheDirectory = "pufferpanel";
+        CacheDirectoryMode = "0700";
+        LogsDirectory = "pufferpanel";
+        LogsDirectoryMode = "0700";
+
+        EnvironmentFile = cfg.environmentFile;
+
+        # Command "pufferpanel shutdown --pid $MAINPID" sends SIGTERM (code 15)
+        # to the main process and waits for termination. This is essentially
+        # KillMode=mixed we are using here. See
+        # https://freedesktop.org/software/systemd/man/systemd.kill.html#KillMode=
+        KillMode = "mixed";
+
+        DynamicUser = true;
+        ProtectHome = true;
+        ProtectProc = "invisible";
+        ProtectClock = true;
+        ProtectHostname = true;
+        ProtectControlGroups = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        PrivateUsers = true;
+        PrivateDevices = true;
+        RestrictRealtime = true;
+        RestrictNamespaces = [ "user" "mnt" ]; # allow buildFHSUserEnv
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+        LockPersonality = true;
+        DeviceAllow = [ "" ];
+        DevicePolicy = "closed";
+        CapabilityBoundingSet = [ "" ];
+      };
+    };
+  };
+
+  meta.maintainers = [ lib.maintainers.tie ];
+}
diff --git a/nixos/modules/services/monitoring/grafana-agent.nix b/nixos/modules/services/monitoring/grafana-agent.nix
index 270d888afb78..b7761c34fe51 100644
--- a/nixos/modules/services/monitoring/grafana-agent.nix
+++ b/nixos/modules/services/monitoring/grafana-agent.nix
@@ -140,7 +140,7 @@ in
         # We can't use Environment=HOSTNAME=%H, as it doesn't include the domain part.
         export HOSTNAME=$(< /proc/sys/kernel/hostname)
 
-        exec ${cfg.package}/bin/agent -config.expand-env -config.file ${configFile}
+        exec ${lib.getExe cfg.package} -config.expand-env -config.file ${configFile}
       '';
       serviceConfig = {
         Restart = "always";
diff --git a/nixos/modules/services/monitoring/prometheus/exporters/smartctl.nix b/nixos/modules/services/monitoring/prometheus/exporters/smartctl.nix
index 0c5648c14149..50e1321a1e9c 100644
--- a/nixos/modules/services/monitoring/prometheus/exporters/smartctl.nix
+++ b/nixos/modules/services/monitoring/prometheus/exporters/smartctl.nix
@@ -4,12 +4,12 @@ with lib;
 
 let
   cfg = config.services.prometheus.exporters.smartctl;
-  args = concatStrings [
-    "--web.listen-address=\"${cfg.listenAddress}:${toString cfg.port}\" "
-    "--smartctl.path=\"${pkgs.smartmontools}/bin/smartctl\" "
-    "--smartctl.interval=\"${cfg.maxInterval}\" "
-    "${concatMapStringsSep " " (device: "--smartctl.device=${device}") cfg.devices}"
-  ];
+  args = lib.escapeShellArgs ([
+    "--web.listen-address=${cfg.listenAddress}:${toString cfg.port}"
+    "--smartctl.path=${pkgs.smartmontools}/bin/smartctl"
+    "--smartctl.interval=${cfg.maxInterval}"
+  ] ++ map (device: "--smartctl.device=${device}") cfg.devices
+  ++ cfg.extraFlags);
 in {
   port = 9633;
 
diff --git a/nixos/modules/services/network-filesystems/kubo.nix b/nixos/modules/services/network-filesystems/kubo.nix
index 0cb0e126d4c5..2537bb1b8d80 100644
--- a/nixos/modules/services/network-filesystems/kubo.nix
+++ b/nixos/modules/services/network-filesystems/kubo.nix
@@ -22,6 +22,18 @@ let
 
   configFile = settingsFormat.generate "kubo-config.json" customizedConfig;
 
+  # Create a fake repo containing only the file "api".
+  # $IPFS_PATH will point to this directory instead of the real one.
+  # For some reason the Kubo CLI tools insist on reading the
+  # config file when it exists. But the Kubo daemon sets the file
+  # permissions such that only the ipfs user is allowed to read
+  # this file. This prevents normal users from talking to the daemon.
+  # To work around this terrible design, create a fake repo with no
+  # config file, only an api file and everything should work as expected.
+  fakeKuboRepo = pkgs.writeTextDir "api" ''
+    /unix/run/ipfs.sock
+  '';
+
   kuboFlags = utils.escapeSystemdExecArgs (
     optional cfg.autoMount "--mount" ++
     optional cfg.enableGC "--enable-gc" ++
@@ -38,6 +50,22 @@ let
 
   splitMulitaddr = addrRaw: lib.tail (lib.splitString "/" addrRaw);
 
+  multiaddrsToListenStreams = addrIn:
+    let
+      addrs = if builtins.typeOf addrIn == "list"
+      then addrIn else [ addrIn ];
+      unfilteredResult = map multiaddrToListenStream addrs;
+    in
+      builtins.filter (addr: addr != null) unfilteredResult;
+
+  multiaddrsToListenDatagrams = addrIn:
+    let
+      addrs = if builtins.typeOf addrIn == "list"
+      then addrIn else [ addrIn ];
+      unfilteredResult = map multiaddrToListenDatagram addrs;
+    in
+      builtins.filter (addr: addr != null) unfilteredResult;
+
   multiaddrToListenStream = addrRaw:
     let
       addr = splitMulitaddr addrRaw;
@@ -154,13 +182,18 @@ in
 
           options = {
             Addresses.API = mkOption {
-              type = types.str;
-              default = "/ip4/127.0.0.1/tcp/5001";
-              description = lib.mdDoc "Where Kubo exposes its API to";
+              type = types.oneOf [ types.str (types.listOf types.str) ];
+              default = [ ];
+              description = lib.mdDoc ''
+                Multiaddr or array of multiaddrs describing the address to serve the local HTTP API on.
+                In addition to the multiaddrs listed here, the daemon will also listen on a Unix domain socket.
+                To allow the ipfs CLI tools to communicate with the daemon over that socket,
+                add your user to the correct group, e.g. `users.users.alice.extraGroups = [ config.services.kubo.group ];`
+              '';
             };
 
             Addresses.Gateway = mkOption {
-              type = types.str;
+              type = types.oneOf [ types.str (types.listOf types.str) ];
               default = "/ip4/127.0.0.1/tcp/8080";
               description = lib.mdDoc "Where the IPFS Gateway can be reached";
             };
@@ -248,7 +281,7 @@ in
     ];
 
     environment.systemPackages = [ cfg.package ];
-    environment.variables.IPFS_PATH = cfg.dataDir;
+    environment.variables.IPFS_PATH = fakeKuboRepo;
 
     # https://github.com/lucas-clemente/quic-go/wiki/UDP-Receive-Buffer-Size
     boot.kernel.sysctl."net.core.rmem_max" = mkDefault 2500000;
@@ -319,6 +352,10 @@ in
           # change when the changes are applied. Whyyyyyy.....
           ipfs --offline config replace -
       '';
+      postStop = mkIf cfg.autoMount ''
+        # After an unclean shutdown the fuse mounts at cfg.ipnsMountDir and cfg.ipfsMountDir are locked
+        umount --quiet '${cfg.ipnsMountDir}' '${cfg.ipfsMountDir}' || true
+      '';
       serviceConfig = {
         ExecStart = [ "" "${cfg.package}/bin/ipfs daemon ${kuboFlags}" ];
         User = cfg.user;
@@ -334,27 +371,23 @@ in
       wantedBy = [ "sockets.target" ];
       socketConfig = {
         ListenStream =
-          let
-            fromCfg = multiaddrToListenStream cfg.settings.Addresses.Gateway;
-          in
-          [ "" ] ++ lib.optional (fromCfg != null) fromCfg;
+          [ "" ] ++ (multiaddrsToListenStreams cfg.settings.Addresses.Gateway);
         ListenDatagram =
-          let
-            fromCfg = multiaddrToListenDatagram cfg.settings.Addresses.Gateway;
-          in
-          [ "" ] ++ lib.optional (fromCfg != null) fromCfg;
+          [ "" ] ++ (multiaddrsToListenDatagrams cfg.settings.Addresses.Gateway);
       };
     };
 
     systemd.sockets.ipfs-api = {
       wantedBy = [ "sockets.target" ];
-      # We also include "%t/ipfs.sock" because there is no way to put the "%t"
-      # in the multiaddr.
-      socketConfig.ListenStream =
-        let
-          fromCfg = multiaddrToListenStream cfg.settings.Addresses.API;
-        in
-        [ "" "%t/ipfs.sock" ] ++ lib.optional (fromCfg != null) fromCfg;
+      socketConfig = {
+        # We also include "%t/ipfs.sock" because there is no way to put the "%t"
+        # in the multiaddr.
+        ListenStream =
+          [ "" "%t/ipfs.sock" ] ++ (multiaddrsToListenStreams cfg.settings.Addresses.API);
+        SocketMode = "0660";
+        SocketUser = cfg.user;
+        SocketGroup = cfg.group;
+      };
     };
   };
 
diff --git a/nixos/modules/services/network-filesystems/openafs/server.nix b/nixos/modules/services/network-filesystems/openafs/server.nix
index 1c615d3bfb64..ad0fd7835670 100644
--- a/nixos/modules/services/network-filesystems/openafs/server.nix
+++ b/nixos/modules/services/network-filesystems/openafs/server.nix
@@ -4,7 +4,8 @@
 with import ./lib.nix { inherit config lib pkgs; };
 
 let
-  inherit (lib) concatStringsSep literalExpression mkIf mkOption optionalString types;
+  inherit (lib) concatStringsSep literalExpression mkIf mkOption mkEnableOption
+  optionalString types;
 
   bosConfig = pkgs.writeText "BosConfig" (''
     restrictmode 1
@@ -24,9 +25,15 @@ let
     parm ${openafsSrv}/libexec/openafs/salvageserver ${cfg.roles.fileserver.salvageserverArgs}
     parm ${openafsSrv}/libexec/openafs/dasalvager ${cfg.roles.fileserver.salvagerArgs}
     end
-  '') + (optionalString (cfg.roles.database.enable && cfg.roles.backup.enable) ''
+  '') + (optionalString (cfg.roles.database.enable && cfg.roles.backup.enable && (!cfg.roles.backup.enableFabs)) ''
     bnode simple buserver 1
-    parm ${openafsSrv}/libexec/openafs/buserver ${cfg.roles.backup.buserverArgs} ${optionalString (cfg.roles.backup.cellServDB != []) "-cellservdb /etc/openafs/backup/"}
+    parm ${openafsSrv}/libexec/openafs/buserver ${cfg.roles.backup.buserverArgs} ${optionalString useBuCellServDB "-cellservdb /etc/openafs/backup/"}
+    end
+  '') + (optionalString (cfg.roles.database.enable &&
+                         cfg.roles.backup.enable &&
+                         cfg.roles.backup.enableFabs) ''
+    bnode simple buserver 1
+    parm ${lib.getBin pkgs.fabs}/bin/fabsys server --config ${fabsConfFile} ${cfg.roles.backup.fabsArgs}
     end
   ''));
 
@@ -34,12 +41,27 @@ let
     pkgs.writeText "NetInfo" ((concatStringsSep "\nf " cfg.advertisedAddresses) + "\n")
   else null;
 
-  buCellServDB = pkgs.writeText "backup-cellServDB-${cfg.cellName}" (mkCellServDB cfg.cellName cfg.roles.backup.cellServDB);
+  buCellServDB = pkgs.writeText "backup-cellServDB-${cfg.cellName}"
+    (mkCellServDB cfg.cellName cfg.roles.backup.cellServDB);
+
+  useBuCellServDB = (cfg.roles.backup.cellServDB != []) && (!cfg.roles.backup.enableFabs);
 
   cfg = config.services.openafsServer;
 
   udpSizeStr = toString cfg.udpPacketSize;
 
+  fabsConfFile = pkgs.writeText "fabs.yaml" (builtins.toJSON ({
+    afs = {
+      aklog = cfg.package + "/bin/aklog";
+      cell = cfg.cellName;
+      dumpscan = cfg.package + "/bin/afsdump_scan";
+      fs = cfg.package + "/bin/fs";
+      pts = cfg.package + "/bin/pts";
+      vos = cfg.package + "/bin/vos";
+    };
+    k5start.command = (lib.getBin pkgs.kstart) + "/bin/k5start";
+  } // cfg.roles.backup.fabsExtraConfig));
+
 in {
 
   options = {
@@ -80,8 +102,8 @@ in {
       };
 
       package = mkOption {
-        default = pkgs.openafs.server or pkgs.openafs;
-        defaultText = literalExpression "pkgs.openafs.server or pkgs.openafs";
+        default = pkgs.openafs;
+        defaultText = literalExpression "pkgs.openafs";
         type = types.package;
         description = lib.mdDoc "OpenAFS package for the server binaries";
       };
@@ -154,16 +176,20 @@ in {
         };
 
         backup = {
-          enable = mkOption {
-            default = false;
-            type = types.bool;
-            description = lib.mdDoc ''
-              Backup server role. Use in conjunction with the
-              `database` role to maintain the Backup
-              Database. Normally only used in conjunction with tape storage
-              or IBM's Tivoli Storage Manager.
-            '';
-          };
+          enable = mkEnableOption (lib.mdDoc ''
+            Backup server role. When using OpenAFS built-in buserver, use in conjunction with the
+            `database` role to maintain the Backup
+            Database. Normally only used in conjunction with tape storage
+            or IBM's Tivoli Storage Manager.
+
+            For a modern backup server, enable this role and see
+            {option}`enableFabs`.
+          '');
+
+          enableFabs = mkEnableOption (lib.mdDoc ''
+            FABS, the flexible AFS backup system. It stores volumes as dump files, relying on other
+            pre-existing backup solutions for handling them.
+          '');
 
           buserverArgs = mkOption {
             default = "";
@@ -181,6 +207,30 @@ in {
               other database server machines.
             '';
           };
+
+          fabsArgs = mkOption {
+            default = "";
+            type = types.str;
+            description = lib.mdDoc ''
+              Arguments to the fabsys process. See
+              {manpage}`fabsys_server(1)` and
+              {manpage}`fabsys_config(1)`.
+            '';
+          };
+
+          fabsExtraConfig = mkOption {
+            default = {};
+            type = types.attrs;
+            description = lib.mdDoc ''
+              Additional configuration parameters for the FABS backup server.
+            '';
+            example = literalExpression ''
+            {
+              afs.localauth = true;
+              afs.keytab = config.sops.secrets.fabsKeytab.path;
+            }
+            '';
+          };
         };
       };
 
@@ -239,7 +289,7 @@ in {
         mode = "0644";
       };
       buCellServDB = {
-        enable = (cfg.roles.backup.cellServDB != []);
+        enable = useBuCellServDB;
         text = mkCellServDB cfg.cellName cfg.roles.backup.cellServDB;
         target = "openafs/backup/CellServDB";
       };
@@ -257,7 +307,7 @@ in {
         preStart = ''
           mkdir -m 0755 -p /var/openafs
           ${optionalString (netInfo != null) "cp ${netInfo} /var/openafs/netInfo"}
-          ${optionalString (cfg.roles.backup.cellServDB != []) "cp ${buCellServDB}"}
+          ${optionalString useBuCellServDB "cp ${buCellServDB}"}
         '';
         serviceConfig = {
           ExecStart = "${openafsBin}/bin/bosserver -nofork";
diff --git a/nixos/modules/services/networking/ddclient.nix b/nixos/modules/services/networking/ddclient.nix
index 5e6f5217c0ce..7caee8a8eb3d 100644
--- a/nixos/modules/services/networking/ddclient.nix
+++ b/nixos/modules/services/networking/ddclient.nix
@@ -29,9 +29,9 @@ let
   configFile = if (cfg.configFile != null) then cfg.configFile else configFile';
 
   preStart = ''
-    install ${configFile} /run/${RuntimeDirectory}/ddclient.conf
+    install --mode=600 --owner=$USER ${configFile} /run/${RuntimeDirectory}/ddclient.conf
     ${lib.optionalString (cfg.configFile == null) (if (cfg.protocol == "nsupdate") then ''
-      install ${cfg.passwordFile} /run/${RuntimeDirectory}/ddclient.key
+      install --mode=600 --owner=$USER ${cfg.passwordFile} /run/${RuntimeDirectory}/ddclient.key
     '' else if (cfg.passwordFile != null) then ''
       "${pkgs.replace-secret}/bin/replace-secret" "@password_placeholder@" "${cfg.passwordFile}" "/run/${RuntimeDirectory}/ddclient.conf"
     '' else ''
diff --git a/nixos/modules/services/networking/dhcpd.nix b/nixos/modules/services/networking/dhcpd.nix
index 0bd5e4ef5535..a981a255c3ee 100644
--- a/nixos/modules/services/networking/dhcpd.nix
+++ b/nixos/modules/services/networking/dhcpd.nix
@@ -218,6 +218,13 @@ in
 
     systemd.services = dhcpdService "4" cfg4 // dhcpdService "6" cfg6;
 
+    warnings = [
+      ''
+        The dhcpd4 and dhcpd6 modules will be removed from NixOS 23.11, because ISC DHCP reached its end of life.
+        See https://www.isc.org/blogs/isc-dhcp-eol/ for details.
+        Please switch to a different implementation like kea, systemd-networkd or dnsmasq.
+      ''
+    ];
   };
 
 }
diff --git a/nixos/modules/services/networking/go-neb.nix b/nixos/modules/services/networking/go-neb.nix
index 8c04542c47cc..b65bb5f548ee 100644
--- a/nixos/modules/services/networking/go-neb.nix
+++ b/nixos/modules/services/networking/go-neb.nix
@@ -60,13 +60,12 @@ in {
 
       serviceConfig = {
         ExecStartPre = lib.optional (cfg.secretFile != null)
-          (pkgs.writeShellScript "pre-start" ''
+          ("+" + pkgs.writeShellScript "pre-start" ''
             umask 077
             export $(xargs < ${cfg.secretFile})
             ${pkgs.envsubst}/bin/envsubst -i "${configFile}" > ${finalConfigFile}
             chown go-neb ${finalConfigFile}
           '');
-        PermissionsStartOnly = true;
         RuntimeDirectory = "go-neb";
         ExecStart = "${pkgs.go-neb}/bin/go-neb";
         User = "go-neb";
diff --git a/nixos/modules/services/networking/ivpn.nix b/nixos/modules/services/networking/ivpn.nix
new file mode 100644
index 000000000000..6df630c1f194
--- /dev/null
+++ b/nixos/modules/services/networking/ivpn.nix
@@ -0,0 +1,51 @@
+{ config, lib, pkgs, ... }:
+let
+  cfg = config.services.ivpn;
+in
+with lib;
+{
+  options.services.ivpn = {
+    enable = mkOption {
+      type = types.bool;
+      default = false;
+      description = lib.mdDoc ''
+        This option enables iVPN daemon.
+        This sets {option}`networking.firewall.checkReversePath` to "loose", which might be undesirable for security.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    boot.kernelModules = [ "tun" ];
+
+    environment.systemPackages = with pkgs; [ ivpn ivpn-service ];
+
+    # iVPN writes to /etc/iproute2/rt_tables
+    networking.iproute2.enable = true;
+    networking.firewall.checkReversePath = "loose";
+
+    systemd.services.ivpn-service = {
+      description = "iVPN daemon";
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network.target" ];
+      after = [
+        "network-online.target"
+        "NetworkManager.service"
+        "systemd-resolved.service"
+      ];
+      path = [
+        # Needed for mount
+        "/run/wrappers"
+      ];
+      startLimitBurst = 5;
+      startLimitIntervalSec = 20;
+      serviceConfig = {
+        ExecStart = "${pkgs.ivpn-service}/bin/ivpn-service --logging";
+        Restart = "always";
+        RestartSec = 1;
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ ataraxiasjel ];
+}
diff --git a/nixos/modules/services/networking/peroxide.nix b/nixos/modules/services/networking/peroxide.nix
index 6cac4bf2f89a..885ee1d96cd0 100644
--- a/nixos/modules/services/networking/peroxide.nix
+++ b/nixos/modules/services/networking/peroxide.nix
@@ -9,7 +9,7 @@ let
 in
 {
   options.services.peroxide = {
-    enable = mkEnableOption (lib.mdDoc "enable");
+    enable = mkEnableOption (lib.mdDoc "peroxide");
 
     package = mkPackageOptionMD pkgs "peroxide" {
       default = [ "peroxide" ];
diff --git a/nixos/modules/services/networking/smokeping.nix b/nixos/modules/services/networking/smokeping.nix
index c2c2a370cb00..19ab3f1aa48c 100644
--- a/nixos/modules/services/networking/smokeping.nix
+++ b/nixos/modules/services/networking/smokeping.nix
@@ -339,14 +339,9 @@ in
       };
       preStart = ''
         mkdir -m 0755 -p ${smokepingHome}/cache ${smokepingHome}/data
-        rm -f ${smokepingHome}/cropper
-        ln -s ${cfg.package}/htdocs/cropper ${smokepingHome}/cropper
-        rm -f ${smokepingHome}/css
-        ln -s ${cfg.package}/htdocs/css ${smokepingHome}/css
-        rm -f ${smokepingHome}/js
-        ln -s ${cfg.package}/htdocs/js ${smokepingHome}/js
-        rm -f ${smokepingHome}/smokeping.fcgi
-        ln -s ${cgiHome} ${smokepingHome}/smokeping.fcgi
+        ln -sf ${cfg.package}/htdocs/css ${smokepingHome}/css
+        ln -sf ${cfg.package}/htdocs/js ${smokepingHome}/js
+        ln -sf ${cgiHome} ${smokepingHome}/smokeping.fcgi
         ${cfg.package}/bin/smokeping --check --config=${configPath}
         ${cfg.package}/bin/smokeping --static --config=${configPath}
       '';
diff --git a/nixos/modules/services/networking/ssh/sshd.nix b/nixos/modules/services/networking/ssh/sshd.nix
index 9982da304a48..89ddf8215299 100644
--- a/nixos/modules/services/networking/ssh/sshd.nix
+++ b/nixos/modules/services/networking/ssh/sshd.nix
@@ -536,7 +536,7 @@ in
     # https://github.com/NixOS/nixpkgs/pull/10155
     # https://github.com/NixOS/nixpkgs/pull/41745
     services.openssh.authorizedKeysFiles =
-      [ "%h/.ssh/authorized_keys" "%h/.ssh/authorized_keys2" "/etc/ssh/authorized_keys.d/%u" ];
+      [ "%h/.ssh/authorized_keys" "/etc/ssh/authorized_keys.d/%u" ];
 
     services.openssh.extraConfig = mkOrder 0
       ''
diff --git a/nixos/modules/services/networking/wgautomesh.nix b/nixos/modules/services/networking/wgautomesh.nix
new file mode 100644
index 000000000000..93227a9b625d
--- /dev/null
+++ b/nixos/modules/services/networking/wgautomesh.nix
@@ -0,0 +1,161 @@
+{ lib, config, pkgs, ... }:
+with lib;
+let
+  cfg = config.services.wgautomesh;
+  settingsFormat = pkgs.formats.toml { };
+  configFile =
+    # Have to remove nulls manually as TOML generator will not just skip key
+    # if value is null
+    settingsFormat.generate "wgautomesh-config.toml"
+      (filterAttrs (k: v: v != null)
+        (mapAttrs
+          (k: v:
+            if k == "peers"
+            then map (e: filterAttrs (k: v: v != null) e) v
+            else v)
+          cfg.settings));
+  runtimeConfigFile =
+    if cfg.enableGossipEncryption
+    then "/run/wgautomesh/wgautomesh.toml"
+    else configFile;
+in
+{
+  options.services.wgautomesh = {
+    enable = mkEnableOption (mdDoc "the wgautomesh daemon");
+    logLevel = mkOption {
+      type = types.enum [ "trace" "debug" "info" "warn" "error" ];
+      default = "info";
+      description = mdDoc "wgautomesh log level.";
+    };
+    enableGossipEncryption = mkOption {
+      type = types.bool;
+      default = true;
+      description = mdDoc "Enable encryption of gossip traffic.";
+    };
+    gossipSecretFile = mkOption {
+      type = types.path;
+      description = mdDoc ''
+        File containing the shared secret key to use for gossip encryption.
+        Required if `enableGossipEncryption` is set.
+      '';
+    };
+    enablePersistence = mkOption {
+      type = types.bool;
+      default = true;
+      description = mdDoc "Enable persistence of Wireguard peer info between restarts.";
+    };
+    openFirewall = mkOption {
+      type = types.bool;
+      default = true;
+      description = mdDoc "Automatically open gossip port in firewall (recommended).";
+    };
+    settings = mkOption {
+      type = types.submodule {
+        freeformType = settingsFormat.type;
+        options = {
+
+          interface = mkOption {
+            type = types.str;
+            description = mdDoc ''
+              Wireguard interface to manage (it is NOT created by wgautomesh, you
+              should use another NixOS option to create it such as
+              `networking.wireguard.interfaces.wg0 = {...};`).
+            '';
+            example = "wg0";
+          };
+          gossip_port = mkOption {
+            type = types.port;
+            description = mdDoc ''
+              wgautomesh gossip port, this MUST be the same number on all nodes in
+              the wgautomesh network.
+            '';
+            default = 1666;
+          };
+          lan_discovery = mkOption {
+            type = types.bool;
+            default = true;
+            description = mdDoc "Enable discovery of peers on the same LAN using UDP broadcast.";
+          };
+          upnp_forward_external_port = mkOption {
+            type = types.nullOr types.port;
+            default = null;
+            description = mdDoc ''
+              Public port number to try to redirect to this machine's Wireguard
+              daemon using UPnP IGD.
+            '';
+          };
+          peers = mkOption {
+            type = types.listOf (types.submodule {
+              options = {
+                pubkey = mkOption {
+                  type = types.str;
+                  description = mdDoc "Wireguard public key of this peer.";
+                };
+                address = mkOption {
+                  type = types.str;
+                  description = mdDoc ''
+                    Wireguard address of this peer (a single IP address, multliple
+                    addresses or address ranges are not supported).
+                  '';
+                  example = "10.0.0.42";
+                };
+                endpoint = mkOption {
+                  type = types.nullOr types.str;
+                  description = mdDoc ''
+                    Bootstrap endpoint for connecting to this Wireguard peer if no
+                    other address is known or none are working.
+                  '';
+                  default = null;
+                  example = "wgnode.mydomain.example:51820";
+                };
+              };
+            });
+            default = [ ];
+            description = mdDoc "wgautomesh peer list.";
+          };
+        };
+
+      };
+      default = { };
+      description = mdDoc "Configuration for wgautomesh.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.wgautomesh.settings = {
+      gossip_secret_file = mkIf cfg.enableGossipEncryption "$CREDENTIALS_DIRECTORY/gossip_secret";
+      persist_file = mkIf cfg.enablePersistence "/var/lib/wgautomesh/state";
+    };
+
+    systemd.services.wgautomesh = {
+      path = [ pkgs.wireguard-tools ];
+      environment = { RUST_LOG = "wgautomesh=${cfg.logLevel}"; };
+      description = "wgautomesh";
+      serviceConfig = {
+        Type = "simple";
+
+        ExecStart = "${getExe pkgs.wgautomesh} ${runtimeConfigFile}";
+        Restart = "always";
+        RestartSec = "30";
+        LoadCredential = mkIf cfg.enableGossipEncryption [ "gossip_secret:${cfg.gossipSecretFile}" ];
+
+        ExecStartPre = mkIf cfg.enableGossipEncryption [
+          ''${pkgs.envsubst}/bin/envsubst \
+              -i ${configFile} \
+              -o ${runtimeConfigFile}''
+        ];
+
+        DynamicUser = true;
+        StateDirectory = "wgautomesh";
+        StateDirectoryMode = "0700";
+        RuntimeDirectory = "wgautomesh";
+        AmbientCapabilities = "CAP_NET_ADMIN";
+        CapabilityBoundingSet = "CAP_NET_ADMIN";
+      };
+      wantedBy = [ "multi-user.target" ];
+    };
+    networking.firewall.allowedUDPPorts =
+      mkIf cfg.openFirewall [ cfg.settings.gossip_port ];
+  };
+}
+
diff --git a/nixos/modules/services/networking/wstunnel.nix b/nixos/modules/services/networking/wstunnel.nix
index 440b617f60a3..067d5df48725 100644
--- a/nixos/modules/services/networking/wstunnel.nix
+++ b/nixos/modules/services/networking/wstunnel.nix
@@ -294,7 +294,7 @@ let
         DynamicUser = true;
         SupplementaryGroups = optional (serverCfg.useACMEHost != null) certConfig.group;
         PrivateTmp = true;
-        AmbientCapabilities = optional (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
+        AmbientCapabilities = optionals (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
         NoNewPrivileges = true;
         RestrictNamespaces = "uts ipc pid user cgroup";
         ProtectSystem = "strict";
@@ -340,7 +340,7 @@ let
         EnvironmentFile = optional (clientCfg.environmentFile != null) clientCfg.environmentFile;
         DynamicUser = true;
         PrivateTmp = true;
-        AmbientCapabilities = (optional (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]) ++ (optional ((clientCfg.dynamicToRemote.port or 1024) < 1024 || (any (x: x.local.port < 1024) clientCfg.localToRemote)) [ "CAP_NET_BIND_SERVICE" ]);
+        AmbientCapabilities = (optionals (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]) ++ (optionals ((clientCfg.dynamicToRemote.port or 1024) < 1024 || (any (x: x.local.port < 1024) clientCfg.localToRemote)) [ "CAP_NET_BIND_SERVICE" ]);
         NoNewPrivileges = true;
         RestrictNamespaces = "uts ipc pid user cgroup";
         ProtectSystem = "strict";
diff --git a/nixos/modules/services/printing/cupsd.nix b/nixos/modules/services/printing/cupsd.nix
index 9ac89e057620..f6a23fb900f0 100644
--- a/nixos/modules/services/printing/cupsd.nix
+++ b/nixos/modules/services/printing/cupsd.nix
@@ -317,6 +317,7 @@ in
     environment.etc.cups.source = "/var/lib/cups";
 
     services.dbus.packages = [ cups.out ] ++ optional polkitEnabled cups-pk-helper;
+    services.udev.packages = cfg.drivers;
 
     # Allow asswordless printer admin for members of wheel group
     security.polkit.extraConfig = mkIf polkitEnabled ''
diff --git a/nixos/modules/services/search/qdrant.nix b/nixos/modules/services/search/qdrant.nix
index a843c44dbb5f..e1f7365d951a 100644
--- a/nixos/modules/services/search/qdrant.nix
+++ b/nixos/modules/services/search/qdrant.nix
@@ -100,6 +100,7 @@ in {
       after = [ "network.target" ];
 
       serviceConfig = {
+        LimitNOFILE=65536;
         ExecStart = "${pkgs.qdrant}/bin/qdrant --config-path ${configFile}";
         DynamicUser = true;
         Restart = "on-failure";
diff --git a/nixos/modules/services/security/fail2ban.nix b/nixos/modules/services/security/fail2ban.nix
index ead24d147071..1962d3f59c9f 100644
--- a/nixos/modules/services/security/fail2ban.nix
+++ b/nixos/modules/services/security/fail2ban.nix
@@ -78,6 +78,13 @@ in
         '';
       };
 
+      bantime = mkOption {
+        default = null;
+        type = types.nullOr types.str;
+        example = "10m";
+        description = lib.mdDoc "Number of seconds that a host is banned.";
+      };
+
       maxretry = mkOption {
         default = 3;
         type = types.ints.unsigned;
@@ -320,6 +327,9 @@ in
       ''}
       # Miscellaneous options
       ignoreip    = 127.0.0.1/8 ${optionalString config.networking.enableIPv6 "::1"} ${concatStringsSep " " cfg.ignoreIP}
+      ${optionalString (cfg.bantime != null) ''
+        bantime     = ${cfg.bantime}
+      ''}
       maxretry    = ${toString cfg.maxretry}
       backend     = systemd
       # Actions
diff --git a/nixos/modules/services/security/kanidm.nix b/nixos/modules/services/security/kanidm.nix
index 5583c39368f7..2f19decb5cb1 100644
--- a/nixos/modules/services/security/kanidm.nix
+++ b/nixos/modules/services/security/kanidm.nix
@@ -7,6 +7,18 @@ let
   serverConfigFile = settingsFormat.generate "server.toml" (filterConfig cfg.serverSettings);
   clientConfigFile = settingsFormat.generate "kanidm-config.toml" (filterConfig cfg.clientSettings);
   unixConfigFile = settingsFormat.generate "kanidm-unixd.toml" (filterConfig cfg.unixSettings);
+  certPaths = builtins.map builtins.dirOf [ cfg.serverSettings.tls_chain cfg.serverSettings.tls_key ];
+
+  # Merge bind mount paths and remove paths where a prefix is already mounted.
+  # This makes sure that if e.g. the tls_chain is in the nix store and /nix/store is alread in the mount
+  # paths, no new bind mount is added. Adding subpaths caused problems on ofborg.
+  hasPrefixInList = list: newPath: lib.any (path: lib.hasPrefix (builtins.toString path) (builtins.toString newPath)) list;
+  mergePaths = lib.foldl' (merged: newPath: let
+      # If the new path is a prefix to some existing path, we need to filter it out
+      filteredPaths = lib.filter (p: !lib.hasPrefix (builtins.toString newPath) (builtins.toString p)) merged;
+      # If a prefix of the new path is already in the list, do not add it
+      filteredNew = if hasPrefixInList filteredPaths newPath then [] else [ newPath ];
+    in filteredPaths ++ filteredNew) [];
 
   defaultServiceConfig = {
     BindReadOnlyPaths = [
@@ -16,7 +28,7 @@ let
       "-/etc/hosts"
       "-/etc/localtime"
     ];
-    CapabilityBoundingSet = "";
+    CapabilityBoundingSet = [];
     # ProtectClock= adds DeviceAllow=char-rtc r
     DeviceAllow = "";
     # Implies ProtectSystem=strict, which re-mounts all paths
@@ -216,22 +228,28 @@ in
       description = "kanidm identity management daemon";
       wantedBy = [ "multi-user.target" ];
       after = [ "network.target" ];
-      serviceConfig = defaultServiceConfig // {
-        StateDirectory = "kanidm";
-        StateDirectoryMode = "0700";
-        ExecStart = "${pkgs.kanidm}/bin/kanidmd server -c ${serverConfigFile}";
-        User = "kanidm";
-        Group = "kanidm";
+      serviceConfig = lib.mkMerge [
+        # Merge paths and ignore existing prefixes needs to sidestep mkMerge
+        (defaultServiceConfig // {
+          BindReadOnlyPaths = mergePaths (defaultServiceConfig.BindReadOnlyPaths ++ certPaths);
+        })
+        {
+          StateDirectory = "kanidm";
+          StateDirectoryMode = "0700";
+          ExecStart = "${pkgs.kanidm}/bin/kanidmd server -c ${serverConfigFile}";
+          User = "kanidm";
+          Group = "kanidm";
 
-        AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
-        CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
-        # This would otherwise override the CAP_NET_BIND_SERVICE capability.
-        PrivateUsers = false;
-        # Port needs to be exposed to the host network
-        PrivateNetwork = false;
-        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
-        TemporaryFileSystem = "/:ro";
-      };
+          AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
+          CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
+          # This would otherwise override the CAP_NET_BIND_SERVICE capability.
+          PrivateUsers = lib.mkForce false;
+          # Port needs to be exposed to the host network
+          PrivateNetwork = lib.mkForce false;
+          RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
+          TemporaryFileSystem = "/:ro";
+        }
+      ];
       environment.RUST_LOG = "info";
     };
 
@@ -240,34 +258,32 @@ in
       wantedBy = [ "multi-user.target" ];
       after = [ "network.target" ];
       restartTriggers = [ unixConfigFile clientConfigFile ];
-      serviceConfig = defaultServiceConfig // {
-        CacheDirectory = "kanidm-unixd";
-        CacheDirectoryMode = "0700";
-        RuntimeDirectory = "kanidm-unixd";
-        ExecStart = "${pkgs.kanidm}/bin/kanidm_unixd";
-        User = "kanidm-unixd";
-        Group = "kanidm-unixd";
+      serviceConfig = lib.mkMerge [
+        defaultServiceConfig
+        {
+          CacheDirectory = "kanidm-unixd";
+          CacheDirectoryMode = "0700";
+          RuntimeDirectory = "kanidm-unixd";
+          ExecStart = "${pkgs.kanidm}/bin/kanidm_unixd";
+          User = "kanidm-unixd";
+          Group = "kanidm-unixd";
 
-        BindReadOnlyPaths = [
-          "/nix/store"
-          "-/etc/resolv.conf"
-          "-/etc/nsswitch.conf"
-          "-/etc/hosts"
-          "-/etc/localtime"
-          "-/etc/kanidm"
-          "-/etc/static/kanidm"
-          "-/etc/ssl"
-          "-/etc/static/ssl"
-        ];
-        BindPaths = [
-          # To create the socket
-          "/run/kanidm-unixd:/var/run/kanidm-unixd"
-        ];
-        # Needs to connect to kanidmd
-        PrivateNetwork = false;
-        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
-        TemporaryFileSystem = "/:ro";
-      };
+          BindReadOnlyPaths = [
+            "-/etc/kanidm"
+            "-/etc/static/kanidm"
+            "-/etc/ssl"
+            "-/etc/static/ssl"
+          ];
+          BindPaths = [
+            # To create the socket
+            "/run/kanidm-unixd:/var/run/kanidm-unixd"
+          ];
+          # Needs to connect to kanidmd
+          PrivateNetwork = lib.mkForce false;
+          RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+          TemporaryFileSystem = "/:ro";
+        }
+      ];
       environment.RUST_LOG = "info";
     };
 
diff --git a/nixos/modules/services/system/cachix-watch-store.nix b/nixos/modules/services/system/cachix-watch-store.nix
index 85e9509bcc82..89157b460b9a 100644
--- a/nixos/modules/services/system/cachix-watch-store.nix
+++ b/nixos/modules/services/system/cachix-watch-store.nix
@@ -62,7 +62,13 @@ in
       after = [ "network-online.target" ];
       path = [ config.nix.package ];
       wantedBy = [ "multi-user.target" ];
+      unitConfig = {
+        # allow to restart indefinitely
+        StartLimitIntervalSec = 0;
+      };
       serviceConfig = {
+        # don't put too much stress on the machine when restarting
+        RestartSec = 1;
         # we don't want to kill children processes as those are deployments
         KillMode = "process";
         Restart = "on-failure";
diff --git a/nixos/modules/services/video/rtsp-simple-server.nix b/nixos/modules/services/video/mediamtx.nix
index 2dd62edab787..18a9e3d5fe30 100644
--- a/nixos/modules/services/video/rtsp-simple-server.nix
+++ b/nixos/modules/services/video/mediamtx.nix
@@ -3,19 +3,19 @@
 with lib;
 
 let
-  cfg = config.services.rtsp-simple-server;
-  package = pkgs.rtsp-simple-server;
+  cfg = config.services.mediamtx;
+  package = pkgs.mediamtx;
   format = pkgs.formats.yaml {};
 in
 {
   options = {
-    services.rtsp-simple-server = {
-      enable = mkEnableOption (lib.mdDoc "RTSP Simple Server");
+    services.mediamtx = {
+      enable = mkEnableOption (lib.mdDoc "MediaMTX");
 
       settings = mkOption {
         description = lib.mdDoc ''
-          Settings for rtsp-simple-server.
-          Read more at <https://github.com/aler9/rtsp-simple-server/blob/main/rtsp-simple-server.yml>
+          Settings for MediaMTX.
+          Read more at <https://github.com/aler9/mediamtx/blob/main/mediamtx.yml>
         '';
         type = format.type;
 
@@ -25,7 +25,7 @@ in
             "stdout"
           ];
           # we set this so when the user uses it, it just works (see LogsDirectory below). but it's not used by default.
-          logFile = "/var/log/rtsp-simple-server/rtsp-simple-server.log";
+          logFile = "/var/log/mediamtx/mediamtx.log";
         };
 
         example = {
@@ -40,20 +40,20 @@ in
 
       env = mkOption {
         type = with types; attrsOf anything;
-        description = lib.mdDoc "Extra environment variables for RTSP Simple Server";
+        description = lib.mdDoc "Extra environment variables for MediaMTX";
         default = {};
         example = {
-          RTSP_CONFKEY = "mykey";
+          MTX_CONFKEY = "mykey";
         };
       };
     };
   };
 
   config = mkIf (cfg.enable) {
-    # NOTE: rtsp-simple-server watches this file and automatically reloads if it changes
-    environment.etc."rtsp-simple-server.yaml".source = format.generate "rtsp-simple-server.yaml" cfg.settings;
+    # NOTE: mediamtx watches this file and automatically reloads if it changes
+    environment.etc."mediamtx.yaml".source = format.generate "mediamtx.yaml" cfg.settings;
 
-    systemd.services.rtsp-simple-server = {
+    systemd.services.mediamtx = {
       environment = cfg.env;
 
       after = [ "network.target" ];
@@ -65,15 +65,15 @@ in
 
       serviceConfig = {
         DynamicUser = true;
-        User = "rtsp-simple-server";
-        Group = "rtsp-simple-server";
+        User = "mediamtx";
+        Group = "mediamtx";
 
-        LogsDirectory = "rtsp-simple-server";
+        LogsDirectory = "mediamtx";
 
         # user likely may want to stream cameras, can't hurt to add video group
         SupplementaryGroups = "video";
 
-        ExecStart = "${package}/bin/rtsp-simple-server /etc/rtsp-simple-server.yaml";
+        ExecStart = "${package}/bin/mediamtx /etc/mediamtx.yaml";
       };
     };
   };
diff --git a/nixos/modules/services/video/v4l2-relayd.nix b/nixos/modules/services/video/v4l2-relayd.nix
new file mode 100644
index 000000000000..2a9dbe00158f
--- /dev/null
+++ b/nixos/modules/services/video/v4l2-relayd.nix
@@ -0,0 +1,199 @@
+{ config, lib, pkgs, utils, ... }:
+let
+
+  inherit (lib) attrValues concatStringsSep filterAttrs length listToAttrs literalExpression
+    makeSearchPathOutput mkEnableOption mkIf mkOption nameValuePair optionals types;
+  inherit (utils) escapeSystemdPath;
+
+  cfg = config.services.v4l2-relayd;
+
+  kernelPackages = config.boot.kernelPackages;
+
+  gst = (with pkgs.gst_all_1; [
+    gst-plugins-bad
+    gst-plugins-base
+    gst-plugins-good
+    gstreamer.out
+  ]);
+
+  instanceOpts = { name, ... }: {
+    options = {
+      enable = mkEnableOption (lib.mdDoc "this v4l2-relayd instance");
+
+      name = mkOption {
+        type = types.str;
+        default = name;
+        description = lib.mdDoc ''
+          The name of the instance.
+        '';
+      };
+
+      cardLabel = mkOption {
+        type = types.str;
+        description = lib.mdDoc ''
+          The name the camera will show up as.
+        '';
+      };
+
+      extraPackages = mkOption {
+        type = with types; listOf package;
+        default = [ ];
+        description = lib.mdDoc ''
+          Extra packages to add to {env}`GST_PLUGIN_PATH` for the instance.
+        '';
+      };
+
+      input = {
+        pipeline = mkOption {
+          type = types.str;
+          description = lib.mdDoc ''
+            The gstreamer-pipeline to use for the input-stream.
+          '';
+        };
+
+        format = mkOption {
+          type = types.str;
+          default = "YUY2";
+          description = lib.mdDoc ''
+            The video-format to read from input-stream.
+          '';
+        };
+
+        width = mkOption {
+          type = types.ints.positive;
+          default = 1280;
+          description = lib.mdDoc ''
+            The width to read from input-stream.
+          '';
+        };
+
+        height = mkOption {
+          type = types.ints.positive;
+          default = 720;
+          description = lib.mdDoc ''
+            The height to read from input-stream.
+          '';
+        };
+
+        framerate = mkOption {
+          type = types.ints.positive;
+          default = 30;
+          description = lib.mdDoc ''
+            The framerate to read from input-stream.
+          '';
+        };
+      };
+
+      output = {
+        format = mkOption {
+          type = types.str;
+          default = "YUY2";
+          description = lib.mdDoc ''
+            The video-format to write to output-stream.
+          '';
+        };
+      };
+
+    };
+  };
+
+in
+{
+
+  options.services.v4l2-relayd = {
+
+    instances = mkOption {
+      type = with types; attrsOf (submodule instanceOpts);
+      default = { };
+      example = literalExpression ''
+        {
+          example = {
+            cardLabel = "Example card";
+            input.pipeline = "videotestsrc";
+          };
+        }
+      '';
+      description = lib.mdDoc ''
+        v4l2-relayd instances to be created.
+      '';
+    };
+
+  };
+
+  config =
+    let
+
+      mkInstanceService = instance: {
+        description = "Streaming relay for v4l2loopback using GStreamer";
+
+        after = [ "modprobe@v4l2loopback.service" "systemd-logind.service" ];
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig = {
+          Type = "simple";
+          Restart = "always";
+          PrivateNetwork = true;
+          PrivateTmp = true;
+          LimitNPROC = 1;
+        };
+
+        environment = {
+          GST_PLUGIN_PATH = makeSearchPathOutput "lib" "lib/gstreamer-1.0" (gst ++ instance.extraPackages);
+          V4L2_DEVICE_FILE = "/run/v4l2-relayd-${instance.name}/device";
+        };
+
+        script =
+          let
+            appsrcOptions = concatStringsSep "," [
+              "caps=video/x-raw"
+              "format=${instance.input.format}"
+              "width=${toString instance.input.width}"
+              "height=${toString instance.input.height}"
+              "framerate=${toString instance.input.framerate}/1"
+            ];
+
+            outputPipeline = [
+              "appsrc name=appsrc ${appsrcOptions}"
+              "videoconvert"
+            ] ++ optionals (instance.input.format != instance.output.format) [
+              "video/x-raw,format=${instance.output.format}"
+              "queue"
+            ] ++ [ "v4l2sink name=v4l2sink device=$(cat $V4L2_DEVICE_FILE)" ];
+          in
+          ''
+            exec ${pkgs.v4l2-relayd}/bin/v4l2-relayd -i "${instance.input.pipeline}" -o "${concatStringsSep " ! " outputPipeline}"
+          '';
+
+        preStart = ''
+          mkdir -p $(dirname $V4L2_DEVICE_FILE)
+          ${kernelPackages.v4l2loopback.bin}/bin/v4l2loopback-ctl add -x 1 -n "${instance.cardLabel}" > $V4L2_DEVICE_FILE
+        '';
+
+        postStop = ''
+          ${kernelPackages.v4l2loopback.bin}/bin/v4l2loopback-ctl delete $(cat $V4L2_DEVICE_FILE)
+          rm -rf $(dirname $V4L2_DEVICE_FILE)
+        '';
+      };
+
+      mkInstanceServices = instances: listToAttrs (map
+        (instance:
+          nameValuePair "v4l2-relayd-${escapeSystemdPath instance.name}" (mkInstanceService instance)
+        )
+        instances);
+
+      enabledInstances = attrValues (filterAttrs (n: v: v.enable) cfg.instances);
+
+    in
+    {
+
+      boot = mkIf ((length enabledInstances) > 0) {
+        extraModulePackages = [ kernelPackages.v4l2loopback ];
+        kernelModules = [ "v4l2loopback" ];
+      };
+
+      systemd.services = mkInstanceServices enabledInstances;
+
+    };
+
+  meta.maintainers = with lib.maintainers; [ betaboon ];
+}
diff --git a/nixos/modules/services/web-apps/ihatemoney/default.nix b/nixos/modules/services/web-apps/ihatemoney/default.nix
deleted file mode 100644
index a61aa445f82c..000000000000
--- a/nixos/modules/services/web-apps/ihatemoney/default.nix
+++ /dev/null
@@ -1,153 +0,0 @@
-{ 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 (lib.mdDoc "ihatemoney webapp. Note that this will set uwsgi to emperor mode");
-      backend = mkOption {
-        type = types.enum [ "sqlite" "postgresql" ];
-        default = "sqlite";
-        description = lib.mdDoc ''
-          The database engine to use for ihatemoney.
-          If `postgresql` is selected, then a database called
-          `${db}` will be created. If you disable this option,
-          it will however not be removed.
-        '';
-      };
-      adminHashedPassword = mkOption {
-        type = types.nullOr types.str;
-        default = null;
-        description = lib.mdDoc "The hashed password of the administrator. To obtain it, run `ihatemoney generate_password_hash`";
-      };
-      uwsgiConfig = mkOption {
-        type = types.attrs;
-        example = {
-          http = ":8000";
-        };
-        description = lib.mdDoc "Additional 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 = lib.mdDoc "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 = lib.mdDoc "The email of the sender of ihatemoney emails";
-        };
-      };
-      secureCookie = mkOption {
-        type = types.bool;
-        default = true;
-        description = lib.mdDoc "Use secure cookies. Disable this when ihatemoney is served via http instead of https";
-      };
-      enableDemoProject = mkEnableOption (lib.mdDoc "access to the demo project in ihatemoney");
-      enablePublicProjectCreation = mkEnableOption (lib.mdDoc "permission to create projects in ihatemoney by anyone");
-      enableAdminDashboard = mkEnableOption (lib.mdDoc "ihatemoney admin dashboard");
-      enableCaptcha = mkEnableOption (lib.mdDoc "a simplistic captcha for some forms");
-      legalLink = mkOption {
-        type = types.nullOr types.str;
-        default = null;
-        description = lib.mdDoc "The URL to a page explaining legal statements about your service, eg. GDPR-related information.";
-      };
-      extraConfig = mkOption {
-        type = types.str;
-        default = "";
-        description = lib.mdDoc "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/mastodon.nix b/nixos/modules/services/web-apps/mastodon.nix
index 3e1286dc475d..cf5329be489c 100644
--- a/nixos/modules/services/web-apps/mastodon.nix
+++ b/nixos/modules/services/web-apps/mastodon.nix
@@ -587,6 +587,13 @@ in {
             <option>services.mastodon.smtp.authenticate</option> is enabled.
         '';
       }
+      {
+        assertion = 1 == builtins.length
+          (lib.mapAttrsToList
+            (_: v: builtins.elem "scheduler" v.jobClasses || v.jobClasses == [ ])
+            cfg.sidekiqProcesses);
+        message = "There must be one and only one Sidekiq queue in services.mastodon.sidekiqProcesses with jobClass \"scheduler\".";
+      }
     ];
 
     environment.systemPackages = [ mastodonTootctl ];
diff --git a/nixos/modules/services/web-apps/monica.nix b/nixos/modules/services/web-apps/monica.nix
new file mode 100644
index 000000000000..442044fedb14
--- /dev/null
+++ b/nixos/modules/services/web-apps/monica.nix
@@ -0,0 +1,468 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+with lib; let
+  cfg = config.services.monica;
+  monica = pkgs.monica.override {
+    dataDir = cfg.dataDir;
+  };
+  db = cfg.database;
+  mail = cfg.mail;
+
+  user = cfg.user;
+  group = cfg.group;
+
+  # shell script for local administration
+  artisan = pkgs.writeScriptBin "monica" ''
+    #! ${pkgs.runtimeShell}
+    cd ${monica}
+    sudo() {
+      if [[ "$USER" != ${user} ]]; then
+        exec /run/wrappers/bin/sudo -u ${user} "$@"
+      else
+        exec "$@"
+      fi
+    }
+    sudo ${pkgs.php}/bin/php artisan "$@"
+  '';
+
+  tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
+in {
+  options.services.monica = {
+    enable = mkEnableOption (lib.mdDoc "monica");
+
+    user = mkOption {
+      default = "monica";
+      description = lib.mdDoc "User monica runs as.";
+      type = types.str;
+    };
+
+    group = mkOption {
+      default = "monica";
+      description = lib.mdDoc "Group monica runs as.";
+      type = types.str;
+    };
+
+    appKeyFile = mkOption {
+      description = lib.mdDoc ''
+        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/monica-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 = "monica.example.com";
+      description = lib.mdDoc ''
+        The hostname to serve monica on.
+      '';
+    };
+
+    appURL = mkOption {
+      description = lib.mdDoc ''
+        The root URL that you want to host monica on. All URLs in monica 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 monica: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 = lib.mdDoc "monica data directory";
+      default = "/var/lib/monica";
+      type = types.path;
+    };
+
+    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 = "monica";
+        description = lib.mdDoc "Database name.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = user;
+        defaultText = lib.literalExpression "user";
+        description = lib.mdDoc "Database username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/monica-dbpassword";
+        description = lib.mdDoc ''
+          A file containing the password corresponding to
+          <option>database.user</option>.
+        '';
+      };
+      createLocally = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc "Create the database and database user locally.";
+      };
+    };
+
+    mail = {
+      driver = mkOption {
+        type = types.enum ["smtp" "sendmail"];
+        default = "smtp";
+        description = lib.mdDoc "Mail driver to use.";
+      };
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = lib.mdDoc "Mail host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 1025;
+        description = lib.mdDoc "Mail host port.";
+      };
+      fromName = mkOption {
+        type = types.str;
+        default = "monica";
+        description = lib.mdDoc "Mail \"from\" name.";
+      };
+      from = mkOption {
+        type = types.str;
+        default = "mail@monica.com";
+        description = lib.mdDoc "Mail \"from\" email.";
+      };
+      user = mkOption {
+        type = with types; nullOr str;
+        default = null;
+        example = "monica";
+        description = lib.mdDoc "Mail username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/monica-mailpassword";
+        description = lib.mdDoc ''
+          A file containing the password corresponding to
+          <option>mail.user</option>.
+        '';
+      };
+      encryption = mkOption {
+        type = with types; nullOr (enum ["tls"]);
+        default = null;
+        description = lib.mdDoc "SMTP encryption mechanism to use.";
+      };
+    };
+
+    maxUploadSize = mkOption {
+      type = types.str;
+      default = "18M";
+      example = "1G";
+      description = lib.mdDoc "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 = lib.mdDoc ''
+        Options for the monica 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 = ''
+        {
+          serverAliases = [
+            "monica.''${config.networking.domain}"
+          ];
+          # To enable encryption and let let's encrypt take care of certificate
+          forceSSL = true;
+          enableACME = true;
+        }
+      '';
+      description = lib.mdDoc ''
+        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 = lib.mdDoc ''
+                    The path to a file containing the value the
+                    option should be set to in the final
+                    configuration file.
+                  '';
+                };
+              };
+            })));
+      default = {};
+      example = ''
+        {
+          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 = "monica";
+          OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
+          OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
+          OIDC_ISSUER_DISCOVER = true;
+        }
+      '';
+      description = lib.mdDoc ''
+        monica configuration options to set in the
+        <filename>.env</filename> file.
+
+        Refer to <link xlink:href="https://github.com/monicahq/monica"/>
+        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.monica.database.user must be set to ${user} if services.monica.database.createLocally is set true.";
+      }
+      {
+        assertion = db.createLocally -> db.passwordFile == null;
+        message = "services.monica.database.passwordFile cannot be specified if services.monica.database.createLocally is set to true.";
+      }
+    ];
+
+    services.monica.config = {
+      APP_ENV = "production";
+      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/monica/cache/services.php";
+      APP_PACKAGES_CACHE = "/run/monica/cache/packages.php";
+      APP_CONFIG_CACHE = "/run/monica/cache/config.php";
+      APP_ROUTES_CACHE = "/run/monica/cache/routes-v7.php";
+      APP_EVENTS_CACHE = "/run/monica/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.monica = {
+      inherit user 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;
+      recommendedBrotliSettings = true;
+      recommendedProxySettings = true;
+      virtualHosts.${cfg.hostname} = mkMerge [
+        cfg.nginx
+        {
+          root = mkForce "${monica}/public";
+          locations = {
+            "/" = {
+              index = "index.php";
+              tryFiles = "$uri $uri/ /index.php?$query_string";
+            };
+            "~ \.php$".extraConfig = ''
+              fastcgi_pass unix:${config.services.phpfpm.pools."monica".socket};
+            '';
+            "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
+              extraConfig = "expires 365d;";
+            };
+          };
+        }
+      ];
+    };
+
+    systemd.services.monica-setup = {
+      description = "Preperation tasks for monica";
+      before = ["phpfpm-monica.service"];
+      after = optional db.createLocally "mysql.service";
+      wantedBy = ["multi-user.target"];
+      serviceConfig = {
+        Type = "oneshot";
+        RemainAfterExit = true;
+        User = user;
+        UMask = 077;
+        WorkingDirectory = "${monica}";
+        RuntimeDirectory = "monica/cache";
+        RuntimeDirectoryMode = 0700;
+      };
+      path = [pkgs.replace-secret];
+      script = let
+        isSecret = v: isAttrs v && v ? _secret && isString v._secret;
+        monicaEnvVars = 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;
+        monicaEnv = pkgs.writeText "monica.env" (monicaEnvVars filteredConfig);
+      in ''
+        # error handling
+        set -euo pipefail
+
+        # create .env file
+        install -T -m 0600 -o ${user} ${monicaEnv} "${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 & seed db
+        ${pkgs.php}/bin/php artisan key:generate --force
+        ${pkgs.php}/bin/php artisan setup:production -v --force
+      '';
+    };
+
+    systemd.services.monica-scheduler = {
+      description = "Background tasks for monica";
+      startAt = "minutely";
+      after = ["monica-setup.service"];
+      serviceConfig = {
+        Type = "oneshot";
+        User = user;
+        WorkingDirectory = "${monica}";
+        ExecStart = "${pkgs.php}/bin/php ${monica}/artisan schedule:run -v";
+      };
+    };
+
+    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 == "monica") {
+        monica = {
+          inherit group;
+          isSystemUser = true;
+        };
+        "${config.services.nginx.user}".extraGroups = [group];
+      };
+      groups = mkIf (group == "monica") {
+        monica = {};
+      };
+    };
+  };
+}
+
diff --git a/nixos/modules/services/web-apps/moodle.nix b/nixos/modules/services/web-apps/moodle.nix
index 5f8d9c5b15f4..b617e9a59379 100644
--- a/nixos/modules/services/web-apps/moodle.nix
+++ b/nixos/modules/services/web-apps/moodle.nix
@@ -56,7 +56,7 @@ let
   mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
   pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
 
-  phpExt = pkgs.php80.buildEnv {
+  phpExt = pkgs.php81.buildEnv {
     extensions = { all, ... }: with all; [ iconv mbstring curl openssl tokenizer soap ctype zip gd simplexml dom intl sqlite3 pgsql pdo_sqlite pdo_pgsql pdo_odbc pdo_mysql pdo mysqli session zlib xmlreader fileinfo filter opcache exif sodium ];
     extraConfig = "max_input_vars = 5000";
   };
diff --git a/nixos/modules/services/web-apps/plausible.nix b/nixos/modules/services/web-apps/plausible.nix
index f64254d62524..893dfa10acbc 100644
--- a/nixos/modules/services/web-apps/plausible.nix
+++ b/nixos/modules/services/web-apps/plausible.nix
@@ -9,6 +9,8 @@ in {
   options.services.plausible = {
     enable = mkEnableOption (lib.mdDoc "plausible");
 
+    package = mkPackageOptionMD pkgs "plausible" { };
+
     releaseCookiePath = mkOption {
       type = with types; either str path;
       description = lib.mdDoc ''
@@ -180,12 +182,12 @@ in {
 
     services.epmd.enable = true;
 
-    environment.systemPackages = [ pkgs.plausible ];
+    environment.systemPackages = [ cfg.package ];
 
     systemd.services = mkMerge [
       {
         plausible = {
-          inherit (pkgs.plausible.meta) description;
+          inherit (cfg.package.meta) description;
           documentation = [ "https://plausible.io/docs/self-hosting" ];
           wantedBy = [ "multi-user.target" ];
           after = optional cfg.database.clickhouse.setup "clickhouse.service"
@@ -233,7 +235,7 @@ in {
             SMTP_USER_NAME = cfg.mail.smtp.user;
           });
 
-          path = [ pkgs.plausible ]
+          path = [ cfg.package ]
             ++ optional cfg.database.postgres.setup config.services.postgresql.package;
           script = ''
             export CONFIG_DIR=$CREDENTIALS_DIRECTORY
@@ -241,10 +243,10 @@ in {
             export RELEASE_COOKIE="$(< $CREDENTIALS_DIRECTORY/RELEASE_COOKIE )"
 
             # setup
-            ${pkgs.plausible}/createdb.sh
-            ${pkgs.plausible}/migrate.sh
+            ${cfg.package}/createdb.sh
+            ${cfg.package}/migrate.sh
             ${optionalString cfg.adminUser.activate ''
-              if ! ${pkgs.plausible}/init-admin.sh | grep 'already exists'; then
+              if ! ${cfg.package}/init-admin.sh | grep 'already exists'; then
                 psql -d plausible <<< "UPDATE users SET email_verified=true;"
               fi
             ''}
diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix
index 7811876369b3..4f97fe752ef7 100644
--- a/nixos/modules/services/web-servers/nginx/default.nix
+++ b/nixos/modules/services/web-servers/nginx/default.nix
@@ -31,6 +31,7 @@ let
 
   # Mime.types values are taken from brotli sample configuration - https://github.com/google/ngx_brotli
   # and Nginx Server Configs - https://github.com/h5bp/server-configs-nginx
+  # "text/html" is implicitly included in {brotli,gzip,zstd}_types
   compressMimeTypes = [
     "application/atom+xml"
     "application/geo+json"
@@ -55,7 +56,6 @@ let
     "text/calendar"
     "text/css"
     "text/csv"
-    "text/html"
     "text/javascript"
     "text/markdown"
     "text/plain"
diff --git a/nixos/modules/services/x11/desktop-managers/pantheon.nix b/nixos/modules/services/x11/desktop-managers/pantheon.nix
index 4d0296c8254c..ab4d75b10134 100644
--- a/nixos/modules/services/x11/desktop-managers/pantheon.nix
+++ b/nixos/modules/services/x11/desktop-managers/pantheon.nix
@@ -288,7 +288,7 @@ in
         elementary-music
         elementary-photos
         elementary-screenshot
-        # elementary-tasks
+        elementary-tasks
         elementary-terminal
         elementary-videos
         epiphany
diff --git a/nixos/modules/services/x11/desktop-managers/plasma5.nix b/nixos/modules/services/x11/desktop-managers/plasma5.nix
index 73a864bb95fe..38f932ffb420 100644
--- a/nixos/modules/services/x11/desktop-managers/plasma5.nix
+++ b/nixos/modules/services/x11/desktop-managers/plasma5.nix
@@ -429,7 +429,8 @@ in
             dolphin-plugins
             ffmpegthumbs
             kdegraphics-thumbnailers
-            pkgs.kio-admin
+            kde-inotify-survey
+            kio-admin
             kio-extras
           ];
           optionalPackages = [