about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>2023-12-06 00:02:22 +0000
committerGitHub <noreply@github.com>2023-12-06 00:02:22 +0000
commit2622221e95392f62f81ae60c35504e338e9ac2cf (patch)
treea23c2759fc45495f899f07220bb1d8b1ecde1253 /nixos
parent9497a77fed58d22866d632901da3ed344e82f2b5 (diff)
parentad7955279dd0dcab03e02767a5794668cb160c04 (diff)
downloadnixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar.gz
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar.bz2
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar.lz
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar.xz
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.tar.zst
nixlib-2622221e95392f62f81ae60c35504e338e9ac2cf.zip
Merge master into staging-next
Diffstat (limited to 'nixos')
-rw-r--r--nixos/doc/manual/release-notes/rl-2405.section.md2
-rwxr-xr-xnixos/maintainers/scripts/ec2/create-amis.sh54
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/services/monitoring/ups.nix421
-rw-r--r--nixos/modules/services/security/clamav.nix52
-rw-r--r--nixos/modules/services/web-apps/windmill.nix177
-rw-r--r--nixos/modules/system/boot/networkd.nix2
7 files changed, 640 insertions, 69 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md
index af0e2a2fe40e..ebc67e98b193 100644
--- a/nixos/doc/manual/release-notes/rl-2405.section.md
+++ b/nixos/doc/manual/release-notes/rl-2405.section.md
@@ -27,6 +27,8 @@ The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been m
 
 <!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
 
+- The `power.ups` module now generates `upsd.conf`, `upsd.users` and `upsmon.conf` automatically from a set of new configuration options. This breaks compatibility with existing `power.ups` setups where these files were created manually. Back up these files before upgrading NixOS.
+
 - `mkosi` was updated to v19. Parts of the user interface have changed. Consult the
   [release notes](https://github.com/systemd/mkosi/releases/tag/v19) for a list of changes.
 
diff --git a/nixos/maintainers/scripts/ec2/create-amis.sh b/nixos/maintainers/scripts/ec2/create-amis.sh
index 0c1656efaf1c..d182c5c2a479 100755
--- a/nixos/maintainers/scripts/ec2/create-amis.sh
+++ b/nixos/maintainers/scripts/ec2/create-amis.sh
@@ -27,31 +27,37 @@ var ${bucket:=nixos-amis}
 var ${service_role_name:=vmimport}
 
 # Output of the command:
-# > aws ec2 describe-regions --all-regions --query "Regions[].{Name:RegionName}" --output text | sort
+# $ nix-shell -I nixpkgs=. -p awscli --run 'aws ec2 describe-regions --region us-east-1 --all-regions --query "Regions[].{Name:RegionName}" --output text | sort | sed -e s/^/\ \ /'
 var ${regions:=
-         af-south-1
-         ap-east-1
-         ap-northeast-1
-         ap-northeast-2
-         ap-northeast-3
-         ap-south-1
-         ap-southeast-1
-         ap-southeast-2
-         ap-southeast-3
-         ca-central-1
-         eu-central-1
-         eu-north-1
-         eu-south-1
-         eu-west-1
-         eu-west-2
-         eu-west-3
-         me-south-1
-         sa-east-1
-         us-east-1
-         us-east-2
-         us-west-1
-         us-west-2
-     }
+  af-south-1
+  ap-east-1
+  ap-northeast-1
+  ap-northeast-2
+  ap-northeast-3
+  ap-south-1
+  ap-south-2
+  ap-southeast-1
+  ap-southeast-2
+  ap-southeast-3
+  ap-southeast-4
+  ca-central-1
+  eu-central-1
+  eu-central-2
+  eu-north-1
+  eu-south-1
+  eu-south-2
+  eu-west-1
+  eu-west-2
+  eu-west-3
+  il-central-1
+  me-central-1
+  me-south-1
+  sa-east-1
+  us-east-1
+  us-east-2
+  us-west-1
+  us-west-2
+}
 
 regions=($regions)
 
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index fee7c35ed8f4..4a93cd1fd9c8 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1334,6 +1334,7 @@
   ./services/web-apps/vikunja.nix
   ./services/web-apps/whitebophir.nix
   ./services/web-apps/wiki-js.nix
+  ./services/web-apps/windmill.nix
   ./services/web-apps/wordpress.nix
   ./services/web-apps/writefreely.nix
   ./services/web-apps/youtrack.nix
diff --git a/nixos/modules/services/monitoring/ups.nix b/nixos/modules/services/monitoring/ups.nix
index efef2d777acd..c9dda8a8c093 100644
--- a/nixos/modules/services/monitoring/ups.nix
+++ b/nixos/modules/services/monitoring/ups.nix
@@ -6,9 +6,83 @@ with lib;
 
 let
   cfg = config.power.ups;
-in
+  defaultPort = 3493;
+
+  nutFormat = {
+
+    type = with lib.types; let
+
+      singleAtom = nullOr (oneOf [
+        bool
+        int
+        float
+        str
+      ]) // {
+        description = "atom (null, bool, int, float or string)";
+      };
+
+      in attrsOf (oneOf [
+        singleAtom
+        (listOf (nonEmptyListOf singleAtom))
+      ]);
+
+    generate = name: value:
+      let
+        normalizedValue =
+          lib.mapAttrs (key: val:
+            if lib.isList val
+            then forEach val (elem: if lib.isList elem then elem else [elem])
+            else
+              if val == null
+              then []
+              else [[val]]
+          ) value;
+
+        mkValueString = concatMapStringsSep " " (v:
+          let str = generators.mkValueStringDefault {} v;
+          in
+            # Quote the value if it has spaces and isn't already quoted.
+            if (hasInfix " " str) && !(hasPrefix "\"" str && hasSuffix "\"" str)
+            then "\"${str}\""
+            else str
+        );
+
+      in pkgs.writeText name (lib.generators.toKeyValue {
+        mkKeyValue = generators.mkKeyValueDefault { inherit mkValueString; } " ";
+        listsAsDuplicateKeys = true;
+      } normalizedValue);
+
+  };
+
+  installSecrets = source: target: secrets:
+    pkgs.writeShellScript "installSecrets.sh" ''
+      install -m0600 -D ${source} "${target}"
+      ${concatLines (forEach secrets (name: ''
+        ${pkgs.replace-secret}/bin/replace-secret \
+          '@${name}@' \
+          "$CREDENTIALS_DIRECTORY/${name}" \
+          "${target}"
+      ''))}
+      chmod u-w "${target}"
+    '';
+
+  upsmonConf = nutFormat.generate "upsmon.conf" cfg.upsmon.settings;
+
+  upsdUsers = pkgs.writeText "upsd.users" (let
+    # This looks like INI, but it's not quite because the
+    # 'upsmon' option lacks a '='. See: man upsd.users
+    userConfig = name: user: concatStringsSep "\n      " (concatLists [
+      [
+        "[${name}]"
+        "password = \"@upsdusers_password_${name}@\""
+      ]
+      (optional (user.upsmon != null) "upsmon ${user.upsmon}")
+      (forEach user.actions (action: "actions = ${action}"))
+      (forEach user.instcmds (instcmd: "instcmds = ${instcmd}"))
+    ]);
+  in concatStringsSep "\n\n" (mapAttrsToList userConfig cfg.users));
+
 
-let
   upsOptions = {name, config, ...}:
   {
     options = {
@@ -95,6 +169,213 @@ let
     };
   };
 
+  listenOptions = {
+    options = {
+      address = mkOption {
+        type = types.str;
+        description = lib.mdDoc ''
+          Address of the interface for `upsd` to listen on.
+          See `man upsd.conf` for details.
+        '';
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = defaultPort;
+        description = lib.mdDoc ''
+          TCP port for `upsd` to listen on.
+          See `man upsd.conf` for details.
+        '';
+      };
+    };
+  };
+
+  upsdOptions = {
+    options = {
+      enable = mkOption {
+        type = types.bool;
+        defaultText = literalMD "`true` if `mode` is one of `standalone`, `netserver`";
+        description = mdDoc "Whether to enable `upsd`.";
+      };
+
+      listen = mkOption {
+        type = with types; listOf (submodule listenOptions);
+        default = [];
+        example = [
+          {
+            address = "192.168.50.1";
+          }
+          {
+            address = "::1";
+            port = 5923;
+          }
+        ];
+        description = lib.mdDoc ''
+          Address of the interface for `upsd` to listen on.
+          See `man upsd` for details`.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.lines;
+        default = "";
+        description = lib.mdDoc ''
+          Additional lines to add to `upsd.conf`.
+        '';
+      };
+    };
+
+    config = {
+      enable = mkDefault (elem cfg.mode [ "standalone" "netserver" ]);
+    };
+  };
+
+
+  monitorOptions = { name, config, ... }: {
+    options = {
+      system = mkOption {
+        type = types.str;
+        default = name;
+        description = lib.mdDoc ''
+          Identifier of the UPS to monitor, in this form: `<upsname>[@<hostname>[:<port>]]`
+          See `upsmon.conf` for details.
+        '';
+      };
+
+      powerValue = mkOption {
+        type = types.int;
+        default = 1;
+        description = lib.mdDoc ''
+          Number of power supplies that the UPS feeds on this system.
+          See `upsmon.conf` for details.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        description = lib.mdDoc ''
+          Username from `upsd.users` for accessing this UPS.
+          See `upsmon.conf` for details.
+        '';
+      };
+
+      passwordFile = mkOption {
+        type = types.str;
+        defaultText = literalMD "power.ups.users.\${user}.passwordFile";
+        description = lib.mdDoc ''
+          The full path to a file containing the password from
+          `upsd.users` for accessing this UPS. The password file
+          is read on service start.
+          See `upsmon.conf` for details.
+        '';
+      };
+
+      type = mkOption {
+        type = types.str;
+        default = "master";
+        description = lib.mdDoc ''
+          The relationship with `upsd`.
+          See `upsmon.conf` for details.
+        '';
+      };
+    };
+
+    config = {
+      passwordFile = mkDefault cfg.users.${config.user}.passwordFile;
+    };
+  };
+
+  upsmonOptions = {
+    options = {
+      enable = mkOption {
+        type = types.bool;
+        defaultText = literalMD "`true` if `mode` is one of `standalone`, `netserver`, `netclient`";
+        description = mdDoc "Whether to enable `upsmon`.";
+      };
+
+      monitor = mkOption {
+        type = with types; attrsOf (submodule monitorOptions);
+        default = {};
+        description = lib.mdDoc ''
+          Set of UPS to monitor. See `man upsmon.conf` for details.
+        '';
+      };
+
+      settings = mkOption {
+        type = nutFormat.type;
+        default = {};
+        defaultText = literalMD ''
+          {
+            MINSUPPLIES = 1;
+            RUN_AS_USER = "root";
+            NOTIFYCMD = "''${pkgs.nut}/bin/upssched";
+            SHUTDOWNCMD = "''${pkgs.systemd}/bin/shutdown now";
+          }
+        '';
+        description = mdDoc "Additional settings to add to `upsmon.conf`.";
+        example = literalMD ''
+          {
+            MINSUPPLIES = 2;
+            NOTIFYFLAG = [
+              [ "ONLINE" "SYSLOG+EXEC" ]
+              [ "ONBATT" "SYSLOG+EXEC" ]
+            ];
+          }
+        '';
+      };
+    };
+
+    config = {
+      enable = mkDefault (elem cfg.mode [ "standalone" "netserver" "netclient" ]);
+      settings = {
+        RUN_AS_USER = "root"; # TODO: replace 'root' by another username.
+        MINSUPPLIES = mkDefault 1;
+        NOTIFYCMD = mkDefault "${pkgs.nut}/bin/upssched";
+        SHUTDOWNCMD = mkDefault "${pkgs.systemd}/bin/shutdown now";
+        MONITOR = flip mapAttrsToList cfg.upsmon.monitor (name: monitor: with monitor; [ system powerValue user "\"@upsmon_password_${name}@\"" type ]);
+      };
+    };
+  };
+
+  userOptions = {
+    options = {
+      passwordFile = mkOption {
+        type = types.str;
+        description = lib.mdDoc ''
+          The full path to a file that contains the user's (clear text)
+          password. The password file is read on service start.
+        '';
+      };
+
+      actions = mkOption {
+        type = with types; listOf str;
+        default = [];
+        description = lib.mdDoc ''
+          Allow the user to do certain things with upsd.
+          See `man upsd.users` for details.
+        '';
+      };
+
+      instcmds = mkOption {
+        type = with types; listOf str;
+        default = [];
+        description = lib.mdDoc ''
+          Let the user initiate specific instant commands. Use "ALL" to grant all commands automatically. For the full list of what your UPS supports, use "upscmd -l".
+          See `man upsd.users` for details.
+        '';
+      };
+
+      upsmon = mkOption {
+        type = with types; nullOr str;
+        default = null;
+        description = lib.mdDoc ''
+          Add the necessary actions for a upsmon process to work.
+          See `man upsd.users` for details.
+        '';
+      };
+    };
+  };
+
 in
 
 
@@ -103,19 +384,14 @@ in
     # powerManagement.powerDownCommands
 
     power.ups = {
-      enable = mkOption {
-        default = false;
-        type = with types; bool;
-        description = lib.mdDoc ''
-          Enables support for Power Devices, such as Uninterruptible Power
-          Supplies, Power Distribution Units and Solar Controllers.
-        '';
-      };
+      enable = mkEnableOption (lib.mdDoc ''
+        Enables support for Power Devices, such as Uninterruptible Power
+        Supplies, Power Distribution Units and Solar Controllers.
+      '');
 
-      # This option is not used yet.
       mode = mkOption {
         default = "standalone";
-        type = types.str;
+        type = types.enum [ "none" "standalone" "netserver" "netclient" ];
         description = lib.mdDoc ''
           The MODE determines which part of the NUT is to be started, and
           which configuration files must be modified.
@@ -148,6 +424,13 @@ in
         '';
       };
 
+      openFirewall = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Open ports in the firewall for `upsd`.
+        '';
+      };
 
       maxStartDelay = mkOption {
         default = 45;
@@ -161,6 +444,22 @@ in
         '';
       };
 
+      upsmon = mkOption {
+        default = {};
+        description = lib.mdDoc ''
+          Options for the `upsmon.conf` configuration file.
+        '';
+        type = types.submodule upsmonOptions;
+      };
+
+      upsd = mkOption {
+        default = {};
+        description = lib.mdDoc ''
+          Options for the `upsd.conf` configuration file.
+        '';
+        type = types.submodule upsdOptions;
+      };
+
       ups = mkOption {
         default = {};
         # see nut/etc/ups.conf.sample
@@ -172,46 +471,95 @@ in
         type = with types; attrsOf (submodule upsOptions);
       };
 
+      users = mkOption {
+        default = {};
+        description = lib.mdDoc ''
+          Users that can access upsd. See `man upsd.users`.
+        '';
+        type = with types; attrsOf (submodule userOptions);
+      };
+
     };
   };
 
   config = mkIf cfg.enable {
 
+    assertions = [
+      (let
+        totalPowerValue = foldl' add 0 (map (monitor: monitor.powerValue) (attrValues cfg.upsmon.monitor));
+        minSupplies = cfg.upsmon.settings.MINSUPPLIES;
+      in mkIf cfg.upsmon.enable {
+        assertion = totalPowerValue >= minSupplies;
+        message = ''
+          `power.ups.upsmon`: Total configured power value (${toString totalPowerValue}) must be at least MINSUPPLIES (${toString minSupplies}).
+        '';
+      })
+    ];
+
     environment.systemPackages = [ pkgs.nut ];
 
-    systemd.services.upsmon = {
+    networking.firewall = mkIf cfg.openFirewall {
+      allowedTCPPorts =
+        if cfg.upsd.listen == []
+        then [ defaultPort ]
+        else unique (forEach cfg.upsd.listen (listen: listen.port));
+    };
+
+    systemd.services.upsmon = let
+      secrets = mapAttrsToList (name: monitor: "upsmon_password_${name}") cfg.upsmon.monitor;
+      createUpsmonConf = installSecrets upsmonConf "/run/nut/upsmon.conf" secrets;
+    in {
+      enable = cfg.upsmon.enable;
       description = "Uninterruptible Power Supplies (Monitor)";
       after = [ "network.target" ];
       wantedBy = [ "multi-user.target" ];
-      serviceConfig.Type = "forking";
-      script = "${pkgs.nut}/sbin/upsmon";
-      environment.NUT_CONFPATH = "/etc/nut/";
-      environment.NUT_STATEPATH = "/var/lib/nut/";
+      serviceConfig = {
+        Type = "forking";
+        ExecStartPre = "${createUpsmonConf}";
+        ExecStart = "${pkgs.nut}/sbin/upsmon";
+        ExecReload = "${pkgs.nut}/sbin/upsmon -c reload";
+        LoadCredential = mapAttrsToList (name: monitor: "upsmon_password_${name}:${monitor.passwordFile}") cfg.upsmon.monitor;
+      };
+      environment.NUT_CONFPATH = "/etc/nut";
+      environment.NUT_STATEPATH = "/var/lib/nut";
     };
 
-    systemd.services.upsd = {
+    systemd.services.upsd = let
+      secrets = mapAttrsToList (name: user: "upsdusers_password_${name}") cfg.users;
+      createUpsdUsers = installSecrets upsdUsers "/run/nut/upsd.users" secrets;
+    in {
+      enable = cfg.upsd.enable;
       description = "Uninterruptible Power Supplies (Daemon)";
       after = [ "network.target" "upsmon.service" ];
       wantedBy = [ "multi-user.target" ];
-      serviceConfig.Type = "forking";
-      # TODO: replace 'root' by another username.
-      script = "${pkgs.nut}/sbin/upsd -u root";
-      environment.NUT_CONFPATH = "/etc/nut/";
-      environment.NUT_STATEPATH = "/var/lib/nut/";
+      serviceConfig = {
+        Type = "forking";
+        ExecStartPre = "${createUpsdUsers}";
+        # TODO: replace 'root' by another username.
+        ExecStart = "${pkgs.nut}/sbin/upsd -u root";
+        ExecReload = "${pkgs.nut}/sbin/upsd -c reload";
+        LoadCredential = mapAttrsToList (name: user: "upsdusers_password_${name}:${user.passwordFile}") cfg.users;
+      };
+      environment.NUT_CONFPATH = "/etc/nut";
+      environment.NUT_STATEPATH = "/var/lib/nut";
+      restartTriggers = [
+        config.environment.etc."nut/upsd.conf".source
+      ];
     };
 
     systemd.services.upsdrv = {
+      enable = cfg.upsd.enable;
       description = "Uninterruptible Power Supplies (Register all UPS)";
       after = [ "upsd.service" ];
       wantedBy = [ "multi-user.target" ];
-      # TODO: replace 'root' by another username.
-      script = "${pkgs.nut}/bin/upsdrvctl -u root start";
       serviceConfig = {
         Type = "oneshot";
         RemainAfterExit = true;
+        # TODO: replace 'root' by another username.
+        ExecStart = "${pkgs.nut}/bin/upsdrvctl -u root start";
       };
-      environment.NUT_CONFPATH = "/etc/nut/";
-      environment.NUT_STATEPATH = "/var/lib/nut/";
+      environment.NUT_CONFPATH = "/etc/nut";
+      environment.NUT_STATEPATH = "/var/lib/nut";
     };
 
     environment.etc = {
@@ -223,24 +571,23 @@ in
         ''
           maxstartdelay = ${toString cfg.maxStartDelay}
 
-          ${flip concatStringsSep (forEach (attrValues cfg.ups) (ups: ups.summary)) "
-
-          "}
+          ${concatStringsSep "\n\n" (forEach (attrValues cfg.ups) (ups: ups.summary))}
+        '';
+      "nut/upsd.conf".source = pkgs.writeText "upsd.conf"
+        ''
+          ${concatStringsSep "\n" (forEach cfg.upsd.listen (listen: "LISTEN ${listen.address} ${toString listen.port}"))}
+          ${cfg.upsd.extraConfig}
         '';
       "nut/upssched.conf".source = cfg.schedulerRules;
-      # These file are containing private information and thus should not
-      # be stored inside the Nix store.
-      /*
-      "nut/upsd.conf".source = "";
-      "nut/upsd.users".source = "";
-      "nut/upsmon.conf".source = "";
-      */
+      "nut/upsd.users".source = "/run/nut/upsd.users";
+      "nut/upsmon.conf".source = "/run/nut/upsmon.conf";
     };
 
     power.ups.schedulerRules = mkDefault "${pkgs.nut}/etc/upssched.conf.sample";
 
     systemd.tmpfiles.rules = [
       "d /var/state/ups -"
+      "d /var/lib/nut 700"
     ];
 
 
diff --git a/nixos/modules/services/security/clamav.nix b/nixos/modules/services/security/clamav.nix
index 72a195d3a04e..d3164373ec01 100644
--- a/nixos/modules/services/security/clamav.nix
+++ b/nixos/modules/services/security/clamav.nix
@@ -3,7 +3,6 @@ with lib;
 let
   clamavUser = "clamav";
   stateDir = "/var/lib/clamav";
-  runDir = "/run/clamav";
   clamavGroup = clamavUser;
   cfg = config.services.clamav;
   pkg = pkgs.clamav;
@@ -99,6 +98,29 @@ in
           '';
         };
       };
+
+      scanner = {
+        enable = mkEnableOption (lib.mdDoc "ClamAV scanner");
+
+        interval = mkOption {
+          type = types.str;
+          default = "*-*-* 04:00:00";
+          description = lib.mdDoc ''
+            How often clamdscan is invoked. See systemd.time(7) for more
+            information about the format.
+            By default this runs using 10 cores at most, be sure to run it at a time of low traffic.
+          '';
+        };
+
+        scanDirectories = mkOption {
+          type = with types; listOf str;
+          default = [ "/home" "/var/lib" "/tmp" "/etc" "/var/tmp" ];
+          description = lib.mdDoc ''
+            List of directories to scan.
+            The default includes everything I could think of that is valid for nixos. Feel free to contribute a PR to add to the default if you see something missing.
+          '';
+        };
+      };
     };
   };
 
@@ -117,9 +139,8 @@ in
 
     services.clamav.daemon.settings = {
       DatabaseDirectory = stateDir;
-      LocalSocket = "${runDir}/clamd.ctl";
-      PidFile = "${runDir}/clamd.pid";
-      TemporaryDirectory = "/tmp";
+      LocalSocket = "/run/clamav/clamd.ctl";
+      PidFile = "/run/clamav/clamd.pid";
       User = "clamav";
       Foreground = true;
     };
@@ -182,7 +203,6 @@ in
         ExecStart = "${pkg}/bin/freshclam";
         SuccessExitStatus = "1"; # if databases are up to date
         StateDirectory = "clamav";
-        RuntimeDirectory = "clamav";
         User = clamavUser;
         Group = clamavGroup;
         PrivateTmp = "yes";
@@ -204,7 +224,6 @@ in
       serviceConfig = {
         Type = "oneshot";
         StateDirectory = "clamav";
-        RuntimeDirectory = "clamav";
         User = clamavUser;
         Group = clamavGroup;
         PrivateTmp = "yes";
@@ -230,12 +249,31 @@ in
         Type = "oneshot";
         ExecStart = "${pkgs.fangfrisch}/bin/fangfrisch --conf ${fangfrischConfigFile} refresh";
         StateDirectory = "clamav";
-        RuntimeDirectory = "clamav";
         User = clamavUser;
         Group = clamavGroup;
         PrivateTmp = "yes";
         PrivateDevices = "yes";
       };
     };
+
+    systemd.timers.clamdscan = mkIf cfg.scanner.enable {
+      description = "Timer for ClamAV virus scanner";
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar = cfg.scanner.interval;
+        Unit = "clamdscan.service";
+      };
+    };
+
+    systemd.services.clamdscan = mkIf cfg.scanner.enable {
+      description = "ClamAV virus scanner";
+      after = optionals cfg.updater.enable [ "clamav-freshclam.service" ];
+      wants = optionals cfg.updater.enable [ "clamav-freshclam.service" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        ExecStart = "${pkg}/bin/clamdscan --multiscan --fdpass --infected --allmatch ${lib.concatStringsSep " " cfg.scanner.scanDirectories}";
+      };
+    };
   };
 }
diff --git a/nixos/modules/services/web-apps/windmill.nix b/nixos/modules/services/web-apps/windmill.nix
new file mode 100644
index 000000000000..8e940dabdc1f
--- /dev/null
+++ b/nixos/modules/services/web-apps/windmill.nix
@@ -0,0 +1,177 @@
+{ config, pkgs, lib, ... }:
+
+let
+  cfg = config.services.windmill;
+in
+{
+  options.services.windmill = {
+    enable = lib.mkEnableOption (lib.mdDoc "windmill service");
+
+    serverPort = lib.mkOption {
+      type = lib.types.port;
+      default = 8001;
+      description = lib.mdDoc "Port the windmill server listens on.";
+    };
+
+    lspPort = lib.mkOption {
+      type = lib.types.port;
+      default = 3001;
+      description = lib.mdDoc "Port the windmill lsp listens on.";
+    };
+
+    database = {
+      name = lib.mkOption {
+        type = lib.types.str;
+        # the simplest database setup is to have the database named like the user.
+        default = "windmill";
+        description = lib.mdDoc "Database name.";
+      };
+
+      user = lib.mkOption {
+        type = lib.types.str;
+        # the simplest database setup is to have the database user like the name.
+        default = "windmill";
+        description = lib.mdDoc "Database user.";
+      };
+
+      urlPath = lib.mkOption {
+        type = lib.types.path;
+        description = lib.mdDoc ''
+          Path to the file containing the database url windmill should connect to. This is not deducted from database user and name as it might contain a secret
+        '';
+        example = "config.age.secrets.DATABASE_URL_FILE.path";
+      };
+      createLocally = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        description = lib.mdDoc "Whether to create a local database automatically.";
+      };
+    };
+
+    baseUrl = lib.mkOption {
+      type = lib.types.str;
+      description = lib.mdDoc ''
+        The base url that windmill will be served on.
+      '';
+      example = "https://windmill.example.com";
+    };
+
+    logLevel = lib.mkOption {
+      type = lib.types.enum [ "error" "warn" "info" "debug" "trace" ];
+      default = "info";
+      description = lib.mdDoc "Log level";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+
+    services.postgresql = lib.optionalAttrs (cfg.database.createLocally) {
+      enable = lib.mkDefault true;
+
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        { name = cfg.database.user;
+          ensureDBOwnership = true;
+        }
+      ];
+
+   };
+
+   systemd.services =
+    let
+      serviceConfig = {
+        DynamicUser = true;
+        # using the same user to simplify db connection
+        User = cfg.database.user;
+        ExecStart = "${pkgs.windmill}/bin/windmill";
+
+        Restart = "always";
+        LoadCredential = [
+          "DATABASE_URL_FILE:${cfg.database.urlPath}"
+        ];
+      };
+    in
+    {
+
+    # coming from https://github.com/windmill-labs/windmill/blob/main/init-db-as-superuser.sql
+    # modified to not grant priviledges on all tables
+    # create role windmill_user and windmill_admin only if they don't exist
+    postgresql.postStart = lib.mkIf cfg.database.createLocally (lib.mkAfter ''
+      $PSQL -tA <<"EOF"
+DO $$
+BEGIN
+    IF NOT EXISTS (
+        SELECT FROM pg_catalog.pg_roles
+        WHERE rolname = 'windmill_user'
+    ) THEN
+        CREATE ROLE windmill_user;
+        GRANT ALL PRIVILEGES ON DATABASE ${cfg.database.name} TO windmill_user;
+    ELSE
+      RAISE NOTICE 'Role "windmill_user" already exists. Skipping.';
+    END IF;
+    IF NOT EXISTS (
+        SELECT FROM pg_catalog.pg_roles
+        WHERE rolname = 'windmill_admin'
+    ) THEN
+      CREATE ROLE windmill_admin WITH BYPASSRLS;
+      GRANT windmill_user TO windmill_admin;
+    ELSE
+      RAISE NOTICE 'Role "windmill_admin" already exists. Skipping.';
+    END IF;
+    GRANT windmill_admin TO windmill;
+END
+$$;
+EOF
+    '');
+
+     windmill-server = {
+        description = "Windmill server";
+        after = [ "network.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig = serviceConfig // { StateDirectory = "windmill";};
+
+        environment = {
+          DATABASE_URL_FILE = "%d/DATABASE_URL_FILE";
+          PORT = builtins.toString cfg.serverPort;
+          WM_BASE_URL = cfg.baseUrl;
+          RUST_LOG = cfg.logLevel;
+          MODE = "server";
+        };
+      };
+
+     windmill-worker = {
+        description = "Windmill worker";
+        after = [ "network.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig = serviceConfig // { StateDirectory = "windmill-worker";};
+
+        environment = {
+          DATABASE_URL_FILE = "%d/DATABASE_URL_FILE";
+          WM_BASE_URL = cfg.baseUrl;
+          RUST_LOG = cfg.logLevel;
+          MODE = "worker";
+          WORKER_GROUP = "default";
+          KEEP_JOB_DIR = "false";
+        };
+      };
+
+     windmill-worker-native = {
+        description = "Windmill worker native";
+        after = [ "network.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig = serviceConfig // { StateDirectory = "windmill-worker-native";};
+
+        environment = {
+          DATABASE_URL_FILE = "%d/DATABASE_URL_FILE";
+          WM_BASE_URL = cfg.baseUrl;
+          RUST_LOG = cfg.logLevel;
+          MODE = "worker";
+          WORKER_GROUP = "native";
+        };
+      };
+    };
+  };
+}
diff --git a/nixos/modules/system/boot/networkd.nix b/nixos/modules/system/boot/networkd.nix
index 33261021480f..3e10770812db 100644
--- a/nixos/modules/system/boot/networkd.nix
+++ b/nixos/modules/system/boot/networkd.nix
@@ -1612,7 +1612,7 @@ let
         description = lib.mdDoc ''
           Each attribute in this set specifies an option in the
           `[WireGuardPeer]` section of the unit.  See
-          {manpage}`systemd.network(5)` for details.
+          {manpage}`systemd.netdev(5)` for details.
         '';
       };
     };