summary refs log tree commit diff
path: root/fleet/modules
diff options
context:
space:
mode:
authorV <v@unfathomable.blue>2021-06-09 15:43:16 +0200
committerV <v@unfathomable.blue>2021-08-17 03:09:34 +0200
commitec0965e2672899d25a5a3a8c072de3ea734076a2 (patch)
treeddf53e6cc5ae47fa1a925f7a7d6414ba03718a84 /fleet/modules
parentdb7c54f92f386a94db8af7a12626d2657b4dd640 (diff)
downloadunf-legacy-ec0965e2672899d25a5a3a8c072de3ea734076a2.tar.zst
fleet: init
Co-authored-by: edef <edef@unfathomable.blue>
Change-Id: I36d2c4cca542ed91630b1b832f3c7a7b97b33c65
Diffstat (limited to 'fleet/modules')
-rw-r--r--fleet/modules/acme.nix55
-rw-r--r--fleet/modules/cgiserver.nix73
-rw-r--r--fleet/modules/declarative-git.nix59
-rw-r--r--fleet/modules/mail.nix70
-rw-r--r--fleet/modules/mlmmj.nix271
-rw-r--r--fleet/modules/public-inbox.nix134
-rw-r--r--fleet/modules/web.nix46
7 files changed, 708 insertions, 0 deletions
diff --git a/fleet/modules/acme.nix b/fleet/modules/acme.nix
new file mode 100644
index 0000000..f06ac4e
--- /dev/null
+++ b/fleet/modules/acme.nix
@@ -0,0 +1,55 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  webroot = "/var/lib/acme/challenges";
+in {
+  options = {
+    # We want to set webroot on every single certificate, but trying
+    # to do this by using genAttrs on the certificate attribute names
+    # produces infinite recursion. To get around this, we're instead
+    # setting webroot from within the certificate submodule itself.
+    #
+    # The fact that this is even possible is because submodules are
+    # just like normal modules, insofar that they can have both an
+    # 'options' and a 'config' attribute, both of which are scoped
+    # to the submodule itself. Additionally, normal merging logic
+    # is applied to module options, meaning we can just define the
+    # certificates option again and that'll be handled correctly.
+    #
+    # TODO(V): Add a global security.acme.webroot option to the upstream module
+    security.acme.certs = mkOption {
+      type = types.attrsOf (types.submodule {
+        inherit webroot;
+      });
+    };
+  };
+
+  config = {
+    security.acme = {
+      acceptTerms = true;
+      email = "acme@unfathomable.blue";
+    };
+
+    services.caddy.config = ''
+      ${concatStringsSep ", " (unique (mapAttrsToList (_: cert: "http://${cert.domain}") config.security.acme.certs))} {
+        import all
+
+        route {
+          # TODO(V): make use of the 'file' matcher, so this is guaranteed to never 404?
+          file_server /.well-known/acme-challenge/* {
+            root ${webroot}
+          }
+
+          # Manually handling http:// disables Caddy's automatic HTTPS
+          # redirects for the domain, so let's do that ourselves
+          redir https://{host}{uri} 308
+        }
+      }
+    '';
+  };
+}
diff --git a/fleet/modules/cgiserver.nix b/fleet/modules/cgiserver.nix
new file mode 100644
index 0000000..6cafbe0
--- /dev/null
+++ b/fleet/modules/cgiserver.nix
@@ -0,0 +1,73 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ config, lib, pkgs, modulesPath, ... }:
+
+with lib;
+
+let
+  cfg = config.services.cgiserver;
+
+  inherit (import "${modulesPath}/system/boot/systemd-unit-options.nix" { inherit config lib; })
+    serviceOptions socketOptions;
+
+  # TODO(V): These descriptions could use a bit of work.
+  instanceOpts = { name, ... }: {
+    options = {
+      description = mkOption {
+        description = "Short description of the application.";
+        type = with types; nullOr str;
+        default = null;
+      };
+
+      application = mkOption {
+        description = "Path to the application.";
+        type = types.path;
+      };
+
+      environment = mkOption {
+        description = "Environment variables passed to the application.";
+        type = with types; attrsOf str;
+        default = {};
+      };
+
+      serviceConfig = mkOption {
+        description = "Extra options to put in the [Service] section of the application's service unit.";
+        inherit (serviceOptions.serviceConfig) type;
+        default = {};
+      };
+
+      listenStreams = mkOption {
+        description = "Addresses to listen on, in the format used by the ListenStream option of systemd.socket(5).";
+        inherit (socketOptions.listenStreams) type;
+        default = [ "/run/${name}/${name}.sock" ];
+      };
+    };
+  };
+in {
+  options.services.cgiserver = {
+    instances = mkOption {
+      description = "Definition of CGI application instances.";
+      type = with types; attrsOf (submodule instanceOpts);
+      default = {};
+    };
+  };
+
+  config = {
+    systemd.sockets = mapAttrs (name: config: {
+      inherit (config) listenStreams;
+      wantedBy = [ "sockets.target" ];
+    }) cfg.instances;
+
+    systemd.services = mapAttrs (name: config: {
+      inherit (config) description environment;
+      serviceConfig = {
+        ExecStart = "${pkgs.cgiserver}/bin/cgiserver ${config.application}";
+        DynamicUser = true;
+        # TODO(V): Hardening options
+      } // config.serviceConfig;
+    }) cfg.instances;
+  };
+
+  meta.maintainers = with maintainers; [ V ];
+}
diff --git a/fleet/modules/declarative-git.nix b/fleet/modules/declarative-git.nix
new file mode 100644
index 0000000..ac4bd15
--- /dev/null
+++ b/fleet/modules/declarative-git.nix
@@ -0,0 +1,59 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+# TODO(V): Make the option descriptions not be terrible.
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.declarative.git;
+
+  repoOpts = { name, config, ... }: {
+    options = {
+      description = mkOption {
+        description = "Description of the repository.";
+        type = types.str;
+        # Git defaults to "Unnamed repository; edit this file 'description' to name the repository."
+        # CGit defaults to "[no description]"
+        default = "Unnamed repository; edit this file 'description' to name the repository.";
+      };
+
+      config = mkOption {
+        description = "Git configuration for the repository.";
+        type = types.attrs;  # TODO(V): be more precise
+        default = {};
+      };
+
+      hooks = mkOption {
+        description = "Git hooks for the repository.";
+        type = with types; attrsOf (listOf path);
+        default = {};
+      };
+    };
+  };
+in {
+  options.declarative.git = {
+    repositories = mkOption {
+      description = "Repositories to manage declaratively.";
+      type = types.attrsOf (types.submodule repoOpts);
+      default = {};
+    };
+
+    hooks = mkOption {
+      description = "Git hooks to apply to all declarative repositories.";
+      type = with types; attrsOf (listOf path);
+      default = {};
+    };
+  };
+
+  config.systemd.tmpfiles.packages = mapAttrsToList (name: config:
+    pkgs.declarative-git-repository {
+      path = "/var/lib/git/${name}";
+      inherit (config) description config;
+      hooks = zipAttrsWith (_: concatLists) [ cfg.hooks config.hooks ];
+      user = "git";
+      group = "git";
+    }) cfg.repositories;
+}
diff --git a/fleet/modules/mail.nix b/fleet/modules/mail.nix
new file mode 100644
index 0000000..24f3925
--- /dev/null
+++ b/fleet/modules/mail.nix
@@ -0,0 +1,70 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ config, pkgs, ... }:
+
+{
+  security.acme.certs = {
+    "${config.networking.fqdn}" = {
+      postRun = "systemctl reload postfix.service";
+    };
+
+    # Older mail servers might not support ECDSA
+    "${config.networking.fqdn}-rsa2048" = {
+      domain = config.networking.fqdn;
+      keyType = "rsa2048";
+      postRun = "systemctl reload postfix.service";
+    };
+  };
+
+  services.postfix = {
+    enable = true;
+
+    # 'myhostname' is actually the FQDN, which Postfix incorrectly expects gethostname(3) to return
+    hostname = config.networking.fqdn;
+
+    # TODO(edef): instrument postfix to find out how often opportunistic encryption works, and with which cipher suites/certificates
+    config = {
+      # Disable account enumeration
+      disable_vrfy_command = true;
+
+      # TODO(V): Look into further hardening
+
+      # Block DNSBLed addresses
+      postscreen_dnsbl_sites = [ "zen.spamhaus.org" "ix.dnsbl.manitu.net" ];
+      postscreen_dnsbl_action = "enforce";
+
+      # Block overly eager robots
+      postscreen_greet_action = "enforce";
+
+      # TODO(V): Look into SpamAssassin for more advanced SPAM protection
+
+      # TODO(V): Support https://github.com/NixOS/nixpkgs/pull/89178 so we can remove some of the following boilerplate
+
+      # Outgoing TLS configuration
+      smtp_tls_security_level = "may";
+      smtp_tls_CAfile = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
+      smtp_tls_loglevel = "1";
+      # TODO(V): disable TLSv1 and other insecure versions?
+
+      # Incoming TLS configuration
+      smtpd_tls_security_level = "may";
+      smtpd_tls_chain_files = [
+        # TODO(V): add ed25519, in the bright, wonderful future of cryptography
+        "/var/lib/acme/${config.networking.fqdn}/full.pem"
+        "/var/lib/acme/${config.networking.fqdn}-rsa2048/full.pem"
+      ];
+      smtpd_tls_loglevel = "1";
+      # TODO(V): disable TLSv1 and other insecure versions?
+    };
+  };
+
+  users.users.postfix.extraGroups = [ "acme" ];
+
+  # TODO(V): Figure out how to ensure that Postfix depends on there being a valid cert on
+  # first-run, without causing issues with mail deliverability for an already running service.
+  # Aren't there self-signed certs that the ACME module has for exactly this reason?
+
+  networking.firewall.allowedTCPPorts = [ 25 ];
+}
diff --git a/fleet/modules/mlmmj.nix b/fleet/modules/mlmmj.nix
new file mode 100644
index 0000000..070e5ff
--- /dev/null
+++ b/fleet/modules/mlmmj.nix
@@ -0,0 +1,271 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+# TODO(V): Get mlmmj to log to the journal, somehow. Currently it only writes to a file in each mailing list's directory.
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.mlmmj;
+
+  # TODO(V): Clean this up when upstreaming.
+  encodeKnob = knob:
+         if knob == true then ""
+    else if isInt knob then toString knob
+    else if isList knob then concatStrings (map (line: "${line}\n") knob)
+    else if isString knob then knob
+    else throw "invalid value";
+
+  writeKnob = name: value:
+    pkgs.writeTextDir name
+      (encodeKnob value);
+
+  mergeKnobs = knobs:
+    if isList (head knobs)
+      then concatLists knobs
+      else last knobs;
+
+  lists = flatten
+    (mapAttrsToList (domain: lists:
+      mapAttrsToList (name: config: rec {
+        # The name and domain of this list.
+        inherit name domain;
+
+        # The title of this list.
+        inherit (config) description;
+
+        # Where this list's state is stored
+        path = "/var/spool/mlmmj/${domain}/${name}";
+
+        # The address of this list. Pretty self-explanatory!
+        address = "${name}@${domain}";
+
+        # A dummy address, used for internal routing
+        conduit = "${address}.mlmmj.alt";
+
+        # An identifier safe for inclusion in unit names
+        unitName = "${domain}-${name}";
+
+        # The list's control directory. See http://mlmmj.org/docs/tunables/
+        controlDir = pkgs.buildEnv {
+          name = "mlmmj-control";
+          paths = mapAttrsToList writeKnob (zipAttrsWith (_: mergeKnobs) [
+            {
+              listaddress = address;
+
+              # FIXME(V): This is broken! But it's what the default config that mlmmj-make-ml gives you has.
+              # > mlmmj-send.c:742: No @ in address, ignoring postmaster: Success
+              owner = "postmaster";
+
+              # This is honestly a bit of a mess! These really ought to
+              # be synthesized by the mailing-list software instead.
+              customheaders = [
+                "Reply-To: ${address}"
+                # TODO(V): Ensure the description is quoted/encoded properly
+                "List-ID: ${config.description} <${name}.${domain}>"
+                "List-Post: <mailto:${address}>"
+                "List-Help: <mailto:${name}+help@${domain}>"
+                "List-Subscribe: <mailto:${name}+subscribe@${domain}>"
+                "List-Unsubscribe: <mailto:${name}+unsubscribe@${domain}>"
+              ];
+              # TODO(V): Add some/all of the above headers to delheaders?
+            }
+
+            (optionalAttrs (config.moderators != null) {
+              moderated = true;
+              inherit (config) moderators;
+            })
+
+            cfg.control
+
+            config.control
+          ]);
+        };
+      }) lists) cfg.lists);
+
+  listOpts = {
+    options = {
+      description = mkOption {
+        description = "A description for this list. Used in the List-ID header, and for its public inbox if integration is enabled.";
+        type = types.str;
+        example = "Test mailing list";
+      };
+
+      moderators = mkOption {
+        description = "A list of moderators for this list. See http://mlmmj.org/docs/tunables/ for options that make use of them.";
+        type = with types; nullOr (listOf str);
+        default = null;
+        example = [
+          "jan@example.org"
+        ];
+      };
+
+      control = mkOption {
+        description = "A set of control knobs for this list. See http://mlmmj.org/docs/tunables/ for more information.";
+        # TODO(V): Restrict the ability to include newlines in these strings?
+        type = with types; attrsOf (oneOf [ str int bool (listOf str) ]);
+        default = {};
+        example = {
+          modonlypost = true;
+        };
+      };
+    };
+  };
+in {
+  disabledModules = [
+    "services/mail/mlmmj.nix"
+  ];
+
+  options.services.mlmmj = {
+    enablePostfix = mkEnableOption "Postfix integration";
+    enablePublicInbox = mkEnableOption "public-inbox integration";
+
+    # TODO(V): add a template language option, possibly allowing a custom package or path to be used
+
+    control = mkOption {
+      description = "A set of control knobs applied to all lists. See http://mlmmj.org/docs/tunables/ for more information.";
+      # TODO(V): Restrict the ability to include newlines in these strings?
+      type = with types; attrsOf (oneOf [ str int bool (listOf str) ]);
+      default = {};
+      example = {
+        modonlypost = true;
+      };
+    };
+
+    lists = mkOption {
+      description = "A set of lists to be managed, organised by domain.";
+      type = types.attrsOf (types.attrsOf (types.submodule listOpts));
+      default = {};
+      example = { "example.org".mlmmj-test = {}; };
+    };
+  };
+
+  config = mkMerge [
+    # TODO(V): replace this check with an mlmmj.enable option?
+    (mkIf (lists != []) {
+      users.users.mlmmj = {
+        isSystemUser = true;
+        uid = config.ids.uids.mlmmj;  # Only used for the siphon
+        group = "mlmmj";
+      };
+
+      users.groups.mlmmj = {
+        gid = config.ids.gids.mlmmj;  # Only used for the siphon
+      };
+
+      # This does the equivalent of running mlmmj-make-ml (http://mlmmj.org/docs/mlmmj-make-ml, http://mlmmj.org/docs/readme )
+      systemd.tmpfiles.packages = map ({ unitName, path, controlDir, ... }:
+        pkgs.writeTextDir "lib/tmpfiles.d/mlmmj-${unitName}.conf" (''
+          d ${path} - mlmmj mlmmj
+          f ${path}/index - mlmmj mlmmj
+          L+ ${path}/control - - - - ${controlDir}
+          L+ ${path}/text - - - - ${pkgs.mlmmj}/share/mlmmj/text.skel/en
+        '' + concatMapStrings (dir: ''
+          d ${path}/${dir} - mlmmj mlmmj
+        '') [
+          "incoming" "queue" "queue/discarded" "requeue" "archive"
+          "subconf" "unsubconf"
+          "bounce" "moderation"
+          "subscribers.d" "digesters.d" "nomailsubs.d"
+        ])) lists;
+
+      systemd.services = listToAttrs (map ({ unitName, address, path, ... }:
+        nameValuePair "mlmmj-maintd-${unitName}" {
+          description = "mlmmj list maintenance for ${address}";
+          serviceConfig = {
+            ExecStart = "${pkgs.mlmmj}/bin/mlmmj-maintd -F -L ${path}";
+            # TODO(V): Should this be Type=exec or Type=oneshot?
+            User = "mlmmj";
+            # TODO(V): Hardening
+          };
+        }) lists);
+
+      systemd.timers = listToAttrs (map ({ unitName, address, ... }:
+        nameValuePair "mlmmj-maintd-${unitName}" {
+          description = "mlmmj list maintenance timer for ${address}";
+          wantedBy = [ "timers.target" ];
+          timerConfig.OnCalendar = "00/2:00";  # Every two hours, as suggested by upstream.
+          # TODO(V): set OnBootSec to 0, and use OnUnit(In)ActiveSec, as with the rss2email module, etc?
+          # Is there a compelling reason to actually run this at calendar times?
+          # TODO(V): Increase/decrease the frequency? Every 2 hours is pretty arbitrary, so perhaps we should think a bit about this.
+        }) lists);
+    })
+
+    # TODO(V): make conditional on mlmmj in general, also?
+    # Adapted from http://mlmmj.org/docs/readme-postfix
+    (mkIf cfg.enablePostfix {
+      services.postfix = {
+        masterConfig.mlmmj = {
+          privileged = true;
+          command = "pipe";
+          args = [
+            "flags=ORhu"
+            "user=mlmmj"
+            "argv=${pkgs.mlmmj}/bin/mlmmj-receive -F -L $nexthop"
+          ];
+        };
+
+        # TODO(V): ensure mlmmj is in authorized_submit_users (but only if this is defined!)
+
+        config.mlmmj_destination_recipient_limit = "1";
+
+        # TODO(V): Make this configurable? Make control.delimiter respect this? Get the user to set it instead?
+        recipientDelimiter = "+";
+
+        # TODO(V): Should this module be automatically adding domains to virtual_alias_domains?
+        # Actually, the NixOS Postfix module should probably do this anyway.
+        config.virtual_alias_domains = mapAttrsToList (domain: _: domain) cfg.lists;
+
+        # TODO(V): Handle local domains too, via aliases(5) and a per-domain option.
+        # Actually, doesn't virtual(5) already handle rewrites for local addresses? It doesn't care how addresses are handled
+        virtual = concatStrings (map ({ address, conduit, ... }: "${address} ${address}.mlmmj-siphon.alt, ${conduit}\n") lists);
+
+        # Siphon incoming mail to a Maildir, so we can be sure we're not losing anything important.
+        # TODO(V): Remove this once we're more certain of the stability of our archival setup.
+        config.virtual_mailbox_domains = mapAttrsToList (domain: _: "${domain}.mlmmj-siphon.alt") cfg.lists;
+        config.virtual_mailbox_base = "/var/mail/mlmmj-siphon";
+        config.virtual_mailbox_maps = [ "hash:/etc/postfix/vmailbox" ];
+        config.virtual_uid_maps = "static:${toString config.ids.uids.mlmmj}";
+        config.virtual_gid_maps = "static:${toString config.ids.gids.mlmmj}";
+        mapFiles.vmailbox = pkgs.writeText "postfix-vmailbox"
+          (concatStrings (map ({ address, domain, name, ... }: ''
+            ${address}.mlmmj-siphon.alt ${domain}/${name}/
+          '') lists));
+
+        # TODO(V): Do we want to add owner-{list} -> {list}+owner aliases?
+        # From aliases(5):
+        # > In addition, when an alias exists for owner-name, this will override the enve‐
+        # > lope sender address, so that delivery diagnostics are directed to  owner-name,
+        # > instead  of the originator of the message (for details, see owner_request_spe‐
+        # > cial, expand_owner_alias and reset_owner_alias).  This is  typically  used  to
+        # > direct delivery errors to the maintainer of a mailing list, who is in a better
+        # > position to deal with mailing list delivery problems than  the  originator  of
+        # > the undelivered mail.
+
+        # TODO(V): Should we add {list}-request addresses per https://datatracker.ietf.org/doc/html/rfc2142 ? Seems kind of decrepit.
+
+        transport = concatStrings (map ({ conduit, path, ... }: "${conduit} mlmmj:${path}\n") lists);
+      };
+    })
+
+    (mkIf cfg.enablePublicInbox {
+      # TODO(V): public-inbox integration
+      services.public-inbox = {
+        enable = true;  # TODO: Remove, this should be up to the user to enable?
+
+        inboxes = listToAttrs (map ({ name, domain, description, path, ... }:
+          nameValuePair name {
+            inherit domain description;
+            watch = let maildir = path: pkgs.linkFarm "fake-maildir" [
+              { name = "new"; inherit path; }
+              { name = "cur"; path = /var/empty; }
+            ]; in "maildir:${maildir "${path}/archive"}";
+          }) lists);
+      };
+    })
+  ];
+
+  meta.maintainers = with maintainers; [ V ];
+}
diff --git a/fleet/modules/public-inbox.nix b/fleet/modules/public-inbox.nix
new file mode 100644
index 0000000..a8aa06b
--- /dev/null
+++ b/fleet/modules/public-inbox.nix
@@ -0,0 +1,134 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+# TODO(V): Figure out what the coderepo/cgit stuff is about
+# TODO(V): Consider a different architecture to what we're currently using
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.public-inbox;
+
+  environment.PI_CONFIG = "${pkgs.writeText "public-inbox-config" (generators.toGitINI public-inbox-config)}";
+
+  # TODO(V): Port this to a Nix type
+  # $ng =~ m![^A-Za-z0-9/_\.\-\~\@\+\=:]! and
+  # 	die "--newsgroup `$ng' is not valid\n";
+  # ($ng =~ m!\A\.! || $ng =~ m!\.\z!) and
+  # 	die "--newsgroup `$ng' must not start or end with `.'\n";
+
+  public-inbox-config = recursiveUpdate {
+    publicinbox = mapAttrs (inbox: config: {
+      address = [ "${inbox}@${config.domain}" ];
+      url = "https://${config.domain}/${inbox}";  # TODO(V): Allow using a different location than this
+      inboxdir = "/var/lib/public-inbox/${inbox}.git";
+      inherit (config) watch;
+    }) cfg.inboxes;
+  } cfg.settings;
+
+  inboxOpts = {
+    options = {
+      description = mkOption {
+        description = "Description of the inbox.";
+        type = types.str;
+      };
+
+      domain = mkOption {
+        description = "Domain of the inbox.";
+        type = types.str;
+        example = "example.org";
+      };
+
+      watch = mkOption {
+        description = "Directory to watch for incoming mails in.";
+        type = types.str;
+      };
+    };
+  };
+in {
+  options.services.public-inbox = {
+    enable = mkOption {
+      type = types.bool;
+      default = false;
+    };
+
+    inboxes = mkOption {
+      type = with types; attrsOf (submodule inboxOpts);
+      default = {};
+    };
+
+    settings = mkOption {
+      type = types.attrs;  # TODO: Use freeformType
+      default = {};
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.public-inbox = {
+      isSystemUser = true;
+      group = "public-inbox";
+
+      home = "/var/lib/public-inbox";
+      createHome = true;
+    };
+
+    users.groups.public-inbox = {};
+
+    systemd.services.public-inbox-init = {
+      description = "public-inbox mailing-list archive (initialization)";
+
+      wantedBy = [ "public-inbox-watch.service" "multi-user.target" ];
+      before = [ "public-inbox-watch.service" ];
+
+      # TODO(V): Add ${pi-config.inboxdir}/description to the reload conditions, since new descriptions don't get picked up after being updated.
+      script = concatStrings (mapAttrsToList (inbox: config: let pi-config = public-inbox-config.publicinbox.${inbox}; in ''
+        ${pkgs.public-inbox-init-lite}/bin/public-inbox-init-lite ${inbox} ${pi-config.inboxdir} ${head pi-config.address}
+        ln -sf ${pkgs.writeText "public-inbox-description" config.description} ${pi-config.inboxdir}/description
+        ${pkgs.public-inbox}/bin/public-inbox-index ${pi-config.inboxdir}
+      '') cfg.inboxes);
+
+      serviceConfig = {
+        Type = "oneshot";
+        RemainAfterExit = true;
+        User = "public-inbox";
+      };
+    };
+
+    # FIXME(V): Currently, if new mail appears in the mlmmj archive while public-inbox-watch is not running, it never gets added!
+    # This directly contradicts what the manpage states: "Upon startup, it scans the mailbox for new messages to be imported while it was not running."
+    # This might be a bug in public-inbox!
+    # We currently save a copy of all mails received by mlmmj to ensure none are lost if this happens.
+    systemd.services.public-inbox-watch = {
+      description = "public-inbox mailing-list archive (incoming mail monitor)";
+      wantedBy = [ "multi-user.target" ];
+      inherit environment;
+      serviceConfig = {
+        ExecStart = "${pkgs.public-inbox}/bin/public-inbox-watch";
+        # TODO(V): ExecReload
+        User = "public-inbox";
+        SupplementaryGroups = [ "mlmmj" ];
+      };
+    };
+
+    # TODO(V): Consider using public-inbox.cgi + cgiserver instead?
+    systemd.sockets.public-inbox-httpd = {
+      description = "public-inbox mailing-list archive (web server)";
+      listenStreams = [ "/run/public-inbox/httpd.sock" ];
+      wantedBy = [ "sockets.target" ];
+    };
+
+    systemd.services.public-inbox-httpd = {
+      description = "public-inbox mailing-list archive (web server)";
+      inherit environment;
+      serviceConfig = {
+        ExecStart = "${pkgs.public-inbox}/bin/public-inbox-httpd";
+        DynamicUser = true;
+        SupplementaryGroups = [ "public-inbox" ];
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ V ];
+}
diff --git a/fleet/modules/web.nix b/fleet/modules/web.nix
new file mode 100644
index 0000000..709b1e4
--- /dev/null
+++ b/fleet/modules/web.nix
@@ -0,0 +1,46 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, ... }:
+
+{
+  services.caddy = {
+    enable = true;
+
+    # Snippets must be defined before they are used, so the mkBefore ensures they come first.
+    config = lib.mkBefore ''
+      (all) {
+        log {
+          output file /var/log/caddy/access.log
+        }
+        header -Server
+      }
+
+      http:// {
+        import all
+        redir https://{host}{uri} 308
+      }
+
+      (common) {
+        import all
+
+        encode zstd gzip
+
+        header {
+          Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
+          Content-Security-Policy "script-src 'none'; object-src 'none'"
+          Permissions-Policy "interest-cohort=()"
+          X-Clacks-Overhead "GNU Terry Pratchett"
+        }
+
+        handle_errors {
+          respond "{http.error.status_code} {http.error.status_text}"
+        }
+      }
+    '';
+  };
+
+  systemd.services.caddy.serviceConfig.LogsDirectory = "caddy";
+
+  networking.firewall.interfaces.ens3.allowedTCPPorts = [ 80 443 ];
+}