summary refs log tree commit diff
path: root/fleet
diff options
context:
space:
mode:
Diffstat (limited to 'fleet')
-rw-r--r--fleet/README.adoc12
-rw-r--r--fleet/configuration.nix141
-rw-r--r--fleet/hosts/trieste/build-from-git.nix16
-rw-r--r--fleet/hosts/trieste/cgit/default.nix107
-rw-r--r--fleet/hosts/trieste/cgit/ripple.svg8
-rw-r--r--fleet/hosts/trieste/cgit/un.svg6
-rw-r--r--fleet/hosts/trieste/cgit/unicon.svg6
-rw-r--r--fleet/hosts/trieste/default.nix52
-rw-r--r--fleet/hosts/trieste/git.nix47
-rw-r--r--fleet/hosts/trieste/lists.nix58
-rw-r--r--fleet/hosts/trieste/mail.nix14
-rw-r--r--fleet/hosts/trieste/web.nix32
-rw-r--r--fleet/hosts/vityaz/default.nix112
-rw-r--r--fleet/hosts/vityaz/git.nix67
-rw-r--r--fleet/hosts/vityaz/mail.nix58
-rw-r--r--fleet/hosts/vityaz/mumble.nix21
-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
-rw-r--r--fleet/pkgs/cgiserver/default.nix25
-rw-r--r--fleet/pkgs/declarative-git-repository/default.nix53
-rw-r--r--fleet/pkgs/group-readable-archives.patch22
-rw-r--r--fleet/pkgs/overlay.nix24
-rw-r--r--fleet/pkgs/permission-warnings-only-when-necessary.patch50
-rw-r--r--fleet/pkgs/public-inbox-init-lite/default.nix18
-rw-r--r--fleet/pkgs/public-inbox-init-lite/public-inbox-init-lite60
-rw-r--r--fleet/pkgs/public-inbox/default.nix45
-rwxr-xr-xfleet/test7
32 files changed, 1769 insertions, 0 deletions
diff --git a/fleet/README.adoc b/fleet/README.adoc
new file mode 100644
index 0000000..2a6e014
--- /dev/null
+++ b/fleet/README.adoc
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: V <v@unfathomable.blue>
+// SPDX-License-Identifier: OSL-3.0
+
+= NixOS configuration for Unfathomable infrastructure
+
+Here are the Nix expressions that comprise Unfathomable's infrastructure.
+
+Shared packages and NixOS modules can be found in `pkgs/` and `modules/`, respectively. Host-specific configuration goes under `hosts/<host>`.
+
+== License
+
+Licensed under the Open Software License version 3.0
diff --git a/fleet/configuration.nix b/fleet/configuration.nix
new file mode 100644
index 0000000..2ba819a
--- /dev/null
+++ b/fleet/configuration.nix
@@ -0,0 +1,141 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, modulesPath, ... }:
+
+with lib;
+
+let
+  host = fileContents /etc/hostname;
+  # commit = commitIdFromGitRepo ./.git;
+in {
+  imports = [
+    "${modulesPath}/profiles/qemu-guest.nix"
+    (./hosts + "/${host}")
+  ] ++ mapAttrsToList (module: _: ./modules + "/${module}") (builtins.readDir ./modules);
+
+  nixpkgs.overlays = [ (import ./pkgs/overlay.nix) ];
+
+  system.stateVersion = "20.09";
+
+
+  ### Startup
+
+  boot.loader.grub.device = "/dev/sda";
+
+  boot.initrd = {
+    availableKernelModules = [ "ata_piix" "virtio_pci" "xhci_pci" "sd_mod" "sr_mod" ];
+
+    luks.devices.rpool = {
+      device = "/dev/sda3";
+      allowDiscards = true;
+    };
+
+    network.enable = true;
+
+    network.ssh = {
+      enable = true;
+      port = 798;  # Random unassigned port in the range [1, 1024]
+      hostKeys = [ "/etc/initrd/ssh_host_ed25519_key" ];
+    };
+  };
+
+
+  ### Filesystems
+
+  # Come on, why isn't this the default?
+  boot.tmpOnTmpfs = true;
+
+  # Required by ZFS, but redundant on a single-pathed system.
+  networking.hostId = "00000000";
+
+  fileSystems = {
+    "/boot" = {
+      device = "/dev/sda2";
+      fsType = "ext2";
+    };
+
+    "/" = {
+      device = "rpool/root";
+      fsType = "zfs";
+
+      # Extracted from the strace output of `zfs mount -a`
+      # NOTE: the pool is configured with `zfs set setuid=off rpool`
+      # TODO(V): come up with a less ugly solution
+      options = [ "defaults" "atime" "strictatime" "dev" "exec" "rw" "nosuid" "nomand" "zfsutil" ];
+    };
+  };
+
+
+  ### Networking
+
+  networking.useNetworkd = true;
+
+  networking.hostName = host;
+  networking.domain = "unfathomable.blue";
+
+  # Misnomer, actually enables DHCP for all unmanaged interfaces.
+  # It's also incompatible with systemd-networkd.
+  networking.useDHCP = false;
+
+  networking.interfaces.ens3.useDHCP = true;
+
+  # This is exceedingly spammy, and not so useful for an Internet-facing machine.
+  networking.firewall.logRefusedConnections = false;
+
+
+  ### Security + privacy
+
+  security.sudo.enable = false;
+
+
+  ### System services
+
+  system.autoUpgrade.enable = true;
+
+  services.openssh = {
+    enable = true;
+    passwordAuthentication = false;
+    challengeResponseAuthentication = false;
+    # TODO(V): Route exclusively over WireGuard, if you dare
+  };
+
+
+  ### Programs + user services
+
+  programs.fish.enable = true;
+  programs.mosh.enable = true;
+  programs.mtr.enable = true;
+
+
+  ### Environment
+
+  time.timeZone = "UTC";
+
+  i18n = {
+    defaultLocale = "en_US.UTF-8";
+    supportedLocales = [ "en_US.UTF-8/UTF-8" ];
+    extraLocaleSettings.LC_COLLATE = "C";
+  };
+
+  # TODO(V): Switch to https://github.com/NixOS/nixpkgs/pull/101127 once it's been merged and made its way into stable.
+  users.defaultUserShell = pkgs.fish;
+  environment.variables.EDITOR = "kak";
+
+  environment.systemPackages = with pkgs; [
+    kakoune
+    tree
+    htop
+    ldns
+  ];
+
+
+  ### Users
+
+  users.mutableUsers = false;
+
+  # This is here so we can `git push` directly to /etc/nixos.
+  # It should be removed if we stop using that workflow.
+  users.users.root.packages = [ pkgs.git ];
+}
diff --git a/fleet/hosts/trieste/build-from-git.nix b/fleet/hosts/trieste/build-from-git.nix
new file mode 100644
index 0000000..f04ef48
--- /dev/null
+++ b/fleet/hosts/trieste/build-from-git.nix
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ repo, pkgs ? import <nixpkgs> {} }:
+
+pkgs.callPackage (builtins.fetchGit {
+  url = repo;
+  # While Nix will happily just fetch from HEAD if you only pass in a
+  # path, it will also cache the result for an hour, making it totally
+  # unsuitable for what we're doing. lib.commitIdFromGitRepo, on the
+  # other hand, is implemented purely in Nix and does not cache lookups
+  # from one invocation to the next. This lets us "impurely" fetch from
+  # HEAD while enjoying the niceties of using builtins.fetchGit with a
+  # specific commit hash.
+  rev = pkgs.lib.commitIdFromGitRepo repo;
+}).outPath {}
diff --git a/fleet/hosts/trieste/cgit/default.nix b/fleet/hosts/trieste/cgit/default.nix
new file mode 100644
index 0000000..23e8ab6
--- /dev/null
+++ b/fleet/hosts/trieste/cgit/default.nix
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, ... }:
+
+with lib;
+
+let
+  cgit-webroot = pkgs.runCommand "cgit-webroot" {
+    extraStyles = ''
+      div#cgit table#header td.logo {
+        width: 64px;
+      }
+
+      #summary {
+        max-width: 72ch;
+        margin: auto;
+        font-size: initial;
+      }
+    '';
+    passAsFile = [ "extraStyles" ];
+  } ''
+    ${pkgs.minify}/bin/minify --type css ${pkgs.cgit}/cgit/cgit.css $extraStylesPath -o $out/cgit.css
+    cp ${./un.svg} $out/un.svg  # TODO(V): remove this variant, apply padding to the Sigil using CSS
+    cp ${./unicon.svg} $out/unicon.svg  # This is the same as un.svg, but without any padding
+    cp ${./ripple.svg} $out/ripple.svg  # This is referenced in git.nix (as config.cgit.logo, for Ripple)
+    cp ${pkgs.cgit}/cgit/robots.txt $out
+  '';
+
+  cgit-about-filter = pkgs.writeShellScript "cgit-about-filter" ''
+    # Asciidoctor's embedded mode defaults to eliding the top-level heading, for some reason.
+    # Fortunately we can change this behaviour using the showtitle attribute.
+    # See also: https://github.com/asciidoctor/asciidoctor/issues/1149
+    ${pkgs.asciidoctor}/bin/asciidoctor -e -a showtitle -
+  '';
+
+  cgit-config = pkgs.writeText "cgit-config" ''
+    # TODO(V): sort these sanely
+    root-title=unfathomable software
+    root-desc=
+    # TODO(V): root-readme? what should go in here, contribution info? info about the server? info about the branch conventions?
+    enable-index-owner=0
+
+    logo=/un.svg
+    favicon=/unicon.svg
+    # TODO(V): footer=https://src.unfathomable.blue/nixos-config/commit/?id={commit}
+    mimetype-file=${pkgs.mime-types}/etc/mime.types
+    # TODO(V): repository-sort=age?
+    # TODO(V): robots=none? (same as noindex, nofollow)
+    readme=:README.adoc
+    clone-prefix=https://src.unfathomable.blue
+    agefile=info/last-modified
+    about-filter=${cgit-about-filter}
+    # TODO(edef): commit-filter, for bug tracker links
+    source-filter=${pkgs.cgit}/lib/cgit/filters/syntax-highlighting.py
+    # TODO(edef): add snapshots once we start releasing things
+    # TODO(V): branch-sort=age?
+    enable-git-config=1
+
+    # Has to go last.
+    # Options set after this won't be applied due to how they're evaluated.
+    scan-path=/var/lib/git
+    # TODO(V): section-from-path?
+    # TODO(V): repository-specific logos
+    # TODO(V): other repository-specific options
+  '';
+in {
+  services.cgiserver.instances.cgit = {
+    description = "Lightweight Git web interface";
+    application = "${pkgs.cgit}/cgit/cgit.cgi";
+    environment.CGIT_CONFIG = "${cgit-config}";
+    serviceConfig.SupplementaryGroups = [ "git" ];
+    # TODO(V): Hardening options
+  };
+
+  # TODO(V): set up git-http-backend. Disable enable-http-clone when we've done that?
+  services.caddy.config = ''
+    src.unfathomable.blue {
+      import common
+
+      root * ${cgit-webroot}
+      @exists file
+
+      route {
+        file_server @exists
+        reverse_proxy unix//run/cgit/cgit.sock
+      }
+    }
+  '';
+
+  declarative.git.hooks.post-receive = [
+    # Regenerate the static pack and ref indices used by the dumb git protocol
+    # TODO(V): Remove this once we set up git-http-backend
+    (pkgs.writeShellScript "update-server-info" ''
+      git update-server-info
+    '')
+
+    # Update the last-modified timestamp that cgit uses to measure freshness
+    (pkgs.writeShellScript "update-agefile" ''
+      git for-each-ref \
+        --sort=-creatordate --count=1 \
+        --format='%(creatordate:iso)' \
+        >info/last-modified
+    '')
+  ];
+}
diff --git a/fleet/hosts/trieste/cgit/ripple.svg b/fleet/hosts/trieste/cgit/ripple.svg
new file mode 100644
index 0000000..243059f
--- /dev/null
+++ b/fleet/hosts/trieste/cgit/ripple.svg
@@ -0,0 +1,8 @@
+<!-- SPDX-FileCopyrightText: V <v@unfathomable.blue> -->
+<!-- SPDX-License-Identifier: LicenseRef-NONE -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
+  <circle cx="32" cy="32" r="8"/>
+  <path d="M17.28 38.2l3.5-2.03A12 12 0 0120 32a12 12 0 0110-11.82v-4.04A16 16 0 0016 32a16 16 0 001.28 6.2zM44.71 41.65l-3.5-2.02A12 12 0 0132 44a12 12 0 01-9.21-4.37l-3.5 2.02A16 16 0 0032 48a16 16 0 0012.71-6.35zM34 16.13v4.04A12 12 0 0144 32a12 12 0 01-.78 4.17l3.5 2.02A16 16 0 0048 32a16 16 0 00-14-15.87z" color="#000"/>
+  <path d="M10.3 42.22l3.51-2.03A20 20 0 0112 32a20 20 0 0118-19.84V8.14A24 24 0 008 32a24 24 0 002.3 10.22zM51.67 45.67l-3.44-1.99A20 20 0 0132 52a20 20 0 01-16.23-8.32l-3.44 1.99A24 24 0 0032 56a24 24 0 0019.67-10.33zM34 8.09v4.01A20 20 0 0152 32a20 20 0 01-1.81 8.2l3.5 2.02A24 24 0 0056 32 24 24 0 0034 8.09z" color="#000"/>
+</svg>
diff --git a/fleet/hosts/trieste/cgit/un.svg b/fleet/hosts/trieste/cgit/un.svg
new file mode 100644
index 0000000..a6201bf
--- /dev/null
+++ b/fleet/hosts/trieste/cgit/un.svg
@@ -0,0 +1,6 @@
+<!-- SPDX-FileCopyrightText: V <v@unfathomable.blue> -->
+<!-- SPDX-License-Identifier: LicenseRef-NONE -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
+  <path d="M29.5 47.2v-4.67c-1.26 1.85-2.63 3.23-4.25 4.13c-1.6.89-3.45 1.33-5.57 1.33-3.5 0-6.16-1.09-7.98-3.26-1.8-2.18-2.72-5.36-2.72-9.55V16.74H14v18.25c0 2.89.56 5.05 1.69 6.5 1.12 1.44 2.8 2.15 5.06 2.15 2.7 0 4.83-.86 6.39-2.58 1.58-1.72 2.37-4.07 2.37-7.05V16.74h5.03v4.74c1.2-1.84 2.6-3.2 4.22-4.11 1.63-.91 3.5-1.36 5.63-1.36 3.5 0 6.15 1.09 7.94 3.26 1.8 2.16 2.7 5.34 2.7 9.55v18.39H50V28.99c0-2.89-.56-5.05-1.69-6.48-1.12-1.43-2.8-2.15-5.06-2.15-2.7 0-4.83.86-6.39 2.58-1.56 1.73-2.34 4.08-2.34 7.05v17.22H29.5"/>
+</svg>
diff --git a/fleet/hosts/trieste/cgit/unicon.svg b/fleet/hosts/trieste/cgit/unicon.svg
new file mode 100644
index 0000000..4753d6b
--- /dev/null
+++ b/fleet/hosts/trieste/cgit/unicon.svg
@@ -0,0 +1,6 @@
+<!-- SPDX-FileCopyrightText: V <v@unfathomable.blue> -->
+<!-- SPDX-License-Identifier: LicenseRef-NONE -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
+  <path d="M28.519 53.125v-6.49c-1.752 2.57-3.656 4.489-5.907 5.74-2.224 1.236-4.795 1.848-7.741 1.848-4.864 0-8.561-1.515-11.09-4.53C1.278 46.662 0 42.242 0 36.42V10.792h6.977v25.363c0 4.017.778 7.019 2.349 9.034 1.556 2.001 3.89 2.988 7.032 2.988 3.752 0 6.713-1.195 8.88-3.585 2.197-2.39 3.294-5.657 3.294-9.799V10.792h6.991v6.587c1.668-2.557 3.614-4.447 5.865-5.712 2.265-1.264 4.864-1.89 7.825-1.89 4.864 0 8.547 1.515 11.035 4.53C62.749 17.31 64 21.73 64 27.58V53.14h-6.99V27.817c0-4.017-.779-7.019-2.35-9.006-1.556-1.988-3.89-2.988-7.032-2.988-3.752 0-6.712 1.195-8.88 3.585-2.169 2.405-3.253 5.67-3.253 9.799v23.932H28.52"/>
+</svg>
diff --git a/fleet/hosts/trieste/default.nix b/fleet/hosts/trieste/default.nix
new file mode 100644
index 0000000..08dce1f
--- /dev/null
+++ b/fleet/hosts/trieste/default.nix
@@ -0,0 +1,52 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, ... }:
+
+with lib;
+
+{
+  imports = [
+    ./cgit
+    ./git.nix
+    ./lists.nix
+    ./mail.nix
+    ./web.nix
+  ];
+
+  boot.initrd.network.ssh.authorizedKeys = [
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM3xBRi/sOVJnurXf1McDrODEhU4hCrKZewrUlDmu1Sl v@january"
+    "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+  ];
+
+  # TODO(V): Write a proper description for this
+  # It's b/c the default hosts file is borked
+  # And we need the addresses here b/c for some reason the
+  # stub resolver doesn't return the domain name in PTR records
+  networking.hostFiles = mkForce [
+    (pkgs.writeText "hosts" ''
+      168.119.127.252 trieste.unfathomable.blue
+      2a01:4f8:c2c:b2ae::1:f93f trieste.unfathomable.blue
+    '')
+  ];
+
+  networking.defaultGateway6.address = "fe80::1";
+  networking.interfaces.ens3.ipv6.addresses = singleton {
+    address = "2a01:4f8:c2c:b2ae::1:f93f";
+    prefixLength = 64;
+  };
+
+  services.caddy.config = ''
+    trieste.unfathomable.blue {
+      import common
+      redir / https://en.wikipedia.org/wiki/Trieste_(bathyscaphe)
+      error 404
+    }
+  '';
+
+  users.users.root.openssh.authorizedKeys.keys = [
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILjTET0rm61NIM8C8t95YY8PYGhuieEchTznaaIm/3IK v@january"
+    "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+  ];
+}
diff --git a/fleet/hosts/trieste/git.nix b/fleet/hosts/trieste/git.nix
new file mode 100644
index 0000000..f4d4e0b
--- /dev/null
+++ b/fleet/hosts/trieste/git.nix
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ pkgs, ... }:
+
+let
+  root = "/var/lib/git";
+in {
+  users.users.git = {
+    isSystemUser = true;
+    group = "git";
+
+    # This lets us address remote repositories like `trieste:foo`.
+    home = root;
+
+    # TODO(V): Remove the override once https://github.com/NixOS/nixpkgs/pull/128062 has made its way into stable.
+    shell = pkgs.git // { shellPath = "/bin/git-shell"; };
+
+    openssh.authorizedKeys.keys = [
+      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXELHAMjO/BzoBFgTW9ln3td2WnXw9VGF3zpMBiswsx git@vityaz"
+    ];
+  };
+
+  users.groups.git = {};
+
+  systemd.tmpfiles.rules = [
+    "d ${root} 0750 git git"
+  ];
+
+  declarative.git.repositories = {
+    ripple = {
+      description = "A build system for the next decade";
+      config.cgit = {
+        # This is added to the webroot in cgit.nix. It would be nice if we could do that modularly.
+        # Another option is to simply hotlink https://ripple.unfathomable.blue/icon.svg
+        # Yet another option is to keep the SVG in Git, and link to the raw file from trunk.
+        logo = "/ripple.svg";
+
+        homepage = "https://ripple.unfathomable.blue/";
+      };
+    };
+
+    ripple-website.description = "Source code for https://ripple.unfathomable.blue/";
+    nixos-config.description = "NixOS configuration for Unfathomable infrastructure";
+  };
+}
diff --git a/fleet/hosts/trieste/lists.nix b/fleet/hosts/trieste/lists.nix
new file mode 100644
index 0000000..a4e9a69
--- /dev/null
+++ b/fleet/hosts/trieste/lists.nix
@@ -0,0 +1,58 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, ... }:
+
+with lib;
+
+{
+  # Block HTML e-mail
+  # FIXME(V): This is global, and will affect anyone sending HTML mail to e.g. postmaster@
+  # We should fix this, and limit it to just the list: this is possible using http://mlmmj.org/docs/readme-access/
+  # Unfortunately this doesn't let us pick an error message, though. So maybe not.
+  services.postfix = {
+    enableHeaderChecks = true;
+    headerChecks = [
+      {
+        pattern = ''/^Content-Type: text\/html/'';  # This feels kind of brittle, but should work in 99% of cases.
+        action = "REJECT HTML e-mail is not allowed on this list. See https://useplaintext.email/ for more information.";
+      }
+    ];
+  };
+
+  services.mlmmj = {
+    enablePostfix = true;
+    enablePublicInbox = true;
+
+    control.customheaders = [ "X-Clacks-Overhead: GNU Terry Pratchett" ];
+
+    lists."lists.unfathomable.blue" = {
+      ripple-announce = {
+        description = "Progress updates and other major announcements about Ripple";
+        moderators = [
+          "v@unfathomable.blue"
+          "edef@unfathomable.blue"
+        ];
+        # FIXME(V): This doesn't have quite the effect I was looking for.
+        # It submits non-moderator posts for review, rather than outright rejecting them as I'd wanted.
+        # Perhaps this is good, though, as it allows guest posts?
+        # Downside is there's no immediate rejection, so the user is left with the impression that their mail disappeared…
+        # Maybe http://mlmmj.org/docs/readme-access/ would be more appropriate?
+        control.modonlypost = true;
+      };
+      ripple-devel.description = "Technical discourse and patches for Ripple";
+      ripple-discuss.description = "General discussion about Ripple";
+      # TODO(V): ripple-commits, read-only commit notifications
+    };
+  };
+
+  # By default, the index 404s with the rather confusing message "no inboxes, yet", even when there are inboxes configured.
+  services.public-inbox.settings.publicinbox.wwwlisting = "all";
+
+  services.caddy.config = ''
+    lists.unfathomable.blue {
+      import common
+      reverse_proxy unix//run/public-inbox/httpd.sock
+    }
+  '';
+}
diff --git a/fleet/hosts/trieste/mail.nix b/fleet/hosts/trieste/mail.nix
new file mode 100644
index 0000000..a9258d2
--- /dev/null
+++ b/fleet/hosts/trieste/mail.nix
@@ -0,0 +1,14 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ pkgs, ... }:
+
+{
+  services.postfix = {
+    # Disable delivery to local users
+    localRecipients = [];
+
+    # Forward administrative mail to vityaz
+    postmasterAlias = "postmaster@unfathomable.blue";
+  };
+}
diff --git a/fleet/hosts/trieste/web.nix b/fleet/hosts/trieste/web.nix
new file mode 100644
index 0000000..d32fc44
--- /dev/null
+++ b/fleet/hosts/trieste/web.nix
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ pkgs, ... }:
+
+{
+  systemd.tmpfiles.rules = [
+    "d /var/lib/www - git git"
+  ];
+
+  declarative.git.repositories.ripple-website.hooks.post-receive = [
+    (pkgs.writeShellScript "update-ripple-website" ''
+      nix-build ${./build-from-git.nix} \
+        --argstr repo /var/lib/git/ripple-website \
+        -o /var/lib/www/ripple
+    '')
+  ];
+
+  services.caddy.config = ''
+    unfathomable.blue {
+      import common
+      respond / "the depths await"
+      error 404
+    }
+
+    ripple.unfathomable.blue {
+      import common
+      root * /var/lib/www/ripple
+      file_server
+    }
+  '';
+}
diff --git a/fleet/hosts/vityaz/default.nix b/fleet/hosts/vityaz/default.nix
new file mode 100644
index 0000000..18a4c03
--- /dev/null
+++ b/fleet/hosts/vityaz/default.nix
@@ -0,0 +1,112 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+{
+  imports = [
+    ./git.nix
+    ./mail.nix
+    ./mumble.nix
+  ];
+
+  boot.initrd.network.ssh.authorizedKeys = [
+    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJ8Ms9z95InM7oGJLuo7DdDPh3r5xKnglvBSZ7FTTZ8 v@january"
+    "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+  ];
+
+  # TODO(V): Write a proper description for this
+  # It's b/c the default hosts file is borked
+  # And we need the addresses here b/c for some reason the
+  # stub resolver doesn't return the domain name in PTR records
+  networking.hostFiles = mkForce [
+    (pkgs.writeText "hosts" ''
+      157.90.172.8 vityaz.unfathomable.blue
+      2a01:4f8:1c0c:46a9::1:f93f vityaz.unfathomable.blue
+    '')
+  ];
+
+  networking.defaultGateway6.address = "fe80::1";
+  networking.interfaces.ens3.ipv6.addresses = singleton {
+    address = "2a01:4f8:1c0c:46a9::1:f93f";
+    prefixLength = 64;
+  };
+
+  networking.wireguard.interfaces.wg0 = {
+    ips = [ "10.102.120.0" ];
+    listenPort = 51820;
+    privateKeyFile = "/etc/wireguard/0.key";
+    generatePrivateKeyFile = true;
+
+    peers = mapAttrsToList (address: publicKey: {
+      inherit publicKey;
+      allowedIPs = [ "10.102.120.${address}/32" ];
+    }) {
+      "1" = "z6JrEDvTyIB7cPh4RzeyAihNl+pzgHxv08TMyeynQX4=";  # january
+      "2" = "KSigo7Ny3TTOSPBYDOCVm+K92/pIfgawlfAxK/UBfxA=";  # jaguar
+      "3" = "1EcmBoRykRep8IagzhtJ4zZU0r7gx5W7nZFh2m1wSE8=";  # OnePlus 5T
+      "4" = "TqKlPfBk1McfYNk6S7ZtSj/GnyisGWneozQrh0eh1C8=";  # wallaby
+      "5" = "kuEkbQ+6mOGwkNkOHqpnxM/TI3gpc2sQ6L15UxsOMDI=";  # M1
+    };
+
+    preSetup = ''
+      ${pkgs.iptables}/bin/iptables -A FORWARD -i wg0 -o wg0 -s 10.102.120.0/24 -d 10.102.120.0/24 -j ACCEPT
+    '';
+
+    postShutdown = ''
+      ${pkgs.iptables}/bin/iptables -D FORWARD -i wg0 -o wg0 -s 10.102.120.0/24 -d 10.102.120.0/24 -j ACCEPT
+    '';
+  };
+
+  networking.firewall.interfaces.ens3.allowedUDPPorts = [ config.networking.wireguard.interfaces.wg0.listenPort ];
+
+  networking.firewall.extraCommands = ''
+    iptables -P FORWARD DROP
+  '';
+
+  boot.kernel.sysctl."net.ipv4.conf.wg0.forwarding" = true;
+
+  services.caddy.config = ''
+    vityaz.unfathomable.blue {
+      import common
+      redir / https://en.wikipedia.org/wiki/Vityaz-D_Autonomous_Underwater_Vehicle
+      error 404
+    }
+  '';
+
+  users.users = {
+    root = {
+      openssh.authorizedKeys.keys = [
+        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDz+gGXZUvQiLcDgvon28dErFsbii2cVXJ5wVlsUgaBZ v@january"
+        "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+      ];
+    };
+
+    v = {
+      isNormalUser = true;
+      description = "V";
+
+      openssh.authorizedKeys.keys = [
+        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKMEXEIK2PIRkXYb3RCVN15q9DhKsQlbMhHa5BxQyuz v@january"
+      ];
+
+      packages = with pkgs; [
+      ];
+    };
+
+    edef = {
+      isNormalUser = true;
+      description = "edef";
+
+      openssh.authorizedKeys.keys = [
+        "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+      ];
+
+      packages = with pkgs; [
+      ];
+    };
+  };
+}
diff --git a/fleet/hosts/vityaz/git.nix b/fleet/hosts/vityaz/git.nix
new file mode 100644
index 0000000..66f26db
--- /dev/null
+++ b/fleet/hosts/vityaz/git.nix
@@ -0,0 +1,67 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, pkgs, ... }:
+
+with lib;
+
+{
+  # TODO(edef): could we somehow make this use DynamicUser?
+  users.users.git = {
+    isSystemUser = true;
+
+    group = "git";
+
+    home = "/var/lib/git";
+    createHome = true;
+
+    useDefaultShell = true;
+
+    openssh.authorizedKeys.keys = [
+      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFovWcdS0vQAJiEvwjEIUOv7eip52oX7rVOEMQDJkSL6 v@january"
+      "cert-authority ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvb/7ojfcbKvHIyjnrNUOOgzy44tCkgXY9HLuyFta1jQOE9pFIK19B4dR9bOglPKf145CCL0mSFJNNqmNwwavU2uRn+TQrW+U1dQAk8Gt+gh3O49YE854hwwyMU+xD6bIuUdfxPr+r5al/Ov5Km28ZMlHOs3FoAP0hInK+eAibioxL5rVJOtgicrOVCkGoXEgnuG+LRbOYTwzdClhRUxiPjK8alCbcJQ53AeZHO4G6w9wTr+W5ILCfvW4OmUXCX01sKzaBiQuuFCF6M/H4LlnsPWLMra2twXxkOIhZblwC+lncps9lQaUgiD4koZeOCORvHW00G0L39ilFbbnVcL6Itp/m8RRWm/xRxS4RMnsdV/AhvpRLrhL3lfQ7E2oCeSM36v1S9rdg6a47zcnpL+ahG76Gz39Y7KmVRQciNx7ezbwxj3Q5lZtFykgdfGIAN+bT8ijXMO6m68g60i9Bz4IoMZGkiJGqMYLTxMQ+oRgR3Ro5lbj7E11YBHyeimoBYXYGHMkiuxopQZ7lIj3plxIzhmUlXJBA4jMw9KGHdYaLhaicIYhvQmCTAjrkt2HvxEe6lU8iws2Qv+pB6tAGundN36RVVWAckeQPZ4ZsgDP8V2FfibZ1nsrQ+zBKqaslYMAHs01Cf0Hm0PnCqagf230xaobu0iooNuXx44QKoDnB+w== openpgp:0x803010E7"
+    ];
+
+    packages = with pkgs; [
+      git
+    ];
+  };
+
+  users.groups.git = {};
+
+  # TODO(V): Enable the reflog?
+  declarative.git.repositories = flip genAttrs (repo: {
+    hooks.post-receive = [
+      # FIXME(V): There are more than a number of issues with this!
+      # - non-generic (we could use $GIT_DIR or such)
+      # - requires an explicit remote (we could add this to the config)
+      # - only updates trunk (even if other branches were pushed)
+      # - has no way to filter specific branches from being published
+      # - does not synchronize tags
+      (pkgs.writeShellScript "sync-repository" ''
+        git push trieste:${repo} trunk
+      '')
+    ];
+  }) [
+    # TODO(V): Take the list of public repositories from hosts/trieste/git.nix
+    # (or do the inverse)
+    # (or put this information in a shared location)
+    "ripple"
+    "ripple-website"
+    "nixos-config"
+
+    # Note: private repositories are currently not configured here.
+    # If we find it acceptable to leak their names, they could take advantage of this module as well.
+  ];
+
+  # TODO(V): Linting hooks (honestly, these should just go in CI)
+  # - reuse lint
+  # - check there's a (owner) for every TODO, FIXME, XXX, etc
+  # - make sure everything has been run through rustfmt
+
+  # TODO(V): An equivalent of Bors ("Tolby"?) for our workflow
+  # (or, at least, a queue of commits that must individually pass CI to get merged)
+
+  # TODO(V): Set up CI
+}
diff --git a/fleet/hosts/vityaz/mail.nix b/fleet/hosts/vityaz/mail.nix
new file mode 100644
index 0000000..58d6866
--- /dev/null
+++ b/fleet/hosts/vityaz/mail.nix
@@ -0,0 +1,58 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-FileCopyrightText: edef <edef@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ pkgs, ... }:
+
+{
+  services.postfix = {
+    # TODO(V): Set myorigin to $mydomain?
+
+    # We accept mail to ourselves and to the apex
+    destination = [ "$myhostname" "$mydomain" ];
+
+    # TODO(V): Restrict authorized_submit_users to system users
+
+    # TODO(V): Authenticate users
+    networks = [
+      # Defaults
+      "127.0.0.1/32"
+      "157.90.172.8/32"
+      "10.102.120.0/32"
+      "[::1]/128"
+      "[2a01:4f8:1c0c:46a9::1:f93f]/128"
+      "[fe80::9400:ff:feae:b407]/128"
+
+      # Intranet
+      "10.102.120.0/24"
+    ];
+
+    # Wait, why is this enabled here?
+    recipientDelimiter = "+";
+
+    # TODO(V): postscreen + DNSBLs
+    # TODO(V): postgrey
+
+    rootAlias = "v, edef";
+
+    # TODO(V): Forward mails to root to both edef & V
+    # TODO(V): Forward mails to postmaster to both edef & V
+    # TODO(V): Add extra aliases (Alyssa has abuse, noc, security, hostmaster, usenet, news, webmaster, www, uucp, and ftp)
+    # TODO(V): Add more notify_classes
+  };
+
+  systemd.user.paths.mail = {
+    description = "New mail trigger";
+    wantedBy = [ "paths.target" ];
+    pathConfig.PathChanged = "/var/mail/%u/new";
+    unitConfig.ConditionPathExists = "%h/.notmuch-config";
+  };
+
+  systemd.user.services.mail = {
+    description = "New mail indexing";
+    serviceConfig = {
+      Type = "exec";
+      ExecStart = "${pkgs.notmuch}/bin/notmuch new";
+    };
+  };
+}
diff --git a/fleet/hosts/vityaz/mumble.nix b/fleet/hosts/vityaz/mumble.nix
new file mode 100644
index 0000000..dffc6a6
--- /dev/null
+++ b/fleet/hosts/vityaz/mumble.nix
@@ -0,0 +1,21 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ config, ... }:
+
+{
+  services.murmur = {
+    enable = true;
+
+    # This isn't actually the hostname, it's the address to bind on.
+    hostName = builtins.head config.networking.wireguard.interfaces.wg0.ips;
+
+    # Another misleading name— it's also used as the root channel name.
+    registerName = "Pool";
+  };
+
+  networking.firewall.interfaces.wg0 = {
+    allowedTCPPorts = [ config.services.murmur.port ];
+    allowedUDPPorts = [ config.services.murmur.port ];
+  };
+}
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 ];
+}
diff --git a/fleet/pkgs/cgiserver/default.nix b/fleet/pkgs/cgiserver/default.nix
new file mode 100644
index 0000000..9e911d5
--- /dev/null
+++ b/fleet/pkgs/cgiserver/default.nix
@@ -0,0 +1,25 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, buildGoModule, fetchzip, zstd }:
+
+buildGoModule rec {
+  pname = "cgiserver";
+  version = "1.0.0";
+
+  src = (fetchzip {
+    url = "https://src.anomalous.eu/cgiserver/snapshot/cgiserver-${version}.tar.zst";
+    sha256 = "14bp92sw0w6n5dzs4f7g4fcklh25nc9k0xjx4ia0gi7kn5jwx2mq";
+  }).overrideAttrs ({ nativeBuildInputs, ... }: {
+    nativeBuildInputs = nativeBuildInputs ++ [ zstd ];
+  });
+
+  vendorSha256 = "00jslxzf6p8zs1wxdx3qdb919i80xv4w9ihljd40nnydasshqa4v";
+
+  meta = with lib; {
+    homepage = "https://src.anomalous.eu/cgiserver/about/";
+    description = "Lightweight web server for sandboxing CGI applications";
+    license = licenses.osl3;
+    maintainers = with maintainers; [ V ];
+  };
+}
diff --git a/fleet/pkgs/declarative-git-repository/default.nix b/fleet/pkgs/declarative-git-repository/default.nix
new file mode 100644
index 0000000..f3bb014
--- /dev/null
+++ b/fleet/pkgs/declarative-git-repository/default.nix
@@ -0,0 +1,53 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, writeTextDir, writeText, buildEnv, writeTextFile, bash, writeScript }:
+
+{ path
+, branch ? "trunk"
+, description ? "Unnamed repository; edit this file 'description' to name the repository."
+, config ? {}
+, hooks ? {}
+, user ? "-", group ? "-"
+}:
+
+with lib;
+
+let
+  # As generated by an initial `git init --bare`
+  defaultConfig = {
+    core = {
+      repositoryFormatVersion = 0;
+      fileMode = true;
+      bare = true;
+    };
+  };
+
+  hooksDir = buildEnv {
+    name = "git-repository-hooks";
+    paths = mapAttrsToList (hook: scripts: writeTextFile {
+      name = hook;
+      text = ''
+        #! ${bash}/bin/bash -e
+      '' + concatMapStrings (script: ''
+        ${script} "$@"
+      '') scripts;
+      destination = "/${hook}";
+      executable = true;
+    }) hooks;
+  };
+in writeTextDir "lib/tmpfiles.d/git-repository${replaceStrings [ "/" ] [ "-" ] path}.conf" ''
+  # Root directory needs the correct permissions
+  d ${path} - ${user} ${group}
+
+  # This is the smallest set of paths that Git will still recognise as a valid repository.
+  # Everything else will be automatically filled out after a push or pull.
+  f+ ${path}/HEAD - ${user} ${group} - ref: refs/heads/${branch}
+  d ${path}/objects - ${user} ${group}
+  d ${path}/refs - ${user} ${group}
+
+  # Extra stuff we want to use
+  L+ ${path}/config - - - -  ${writeText "git-repository-config" (generators.toGitINI (recursiveUpdate defaultConfig config))}
+  L+ ${path}/description - - - - ${builtins.toFile "git-repository-description" description}
+  L+ ${path}/hooks - - - - ${hooksDir}
+''
diff --git a/fleet/pkgs/group-readable-archives.patch b/fleet/pkgs/group-readable-archives.patch
new file mode 100644
index 0000000..84b3e07
--- /dev/null
+++ b/fleet/pkgs/group-readable-archives.patch
@@ -0,0 +1,22 @@
+SPDX-FileCopyrightText: V <v@unfathomable.blue>
+SPDX-License-Identifier: OSL-3.0
+--- a/src/mlmmj-process.c
++++ b/src/mlmmj-process.c
+@@ -490,6 +490,9 @@
+ 		{ NULL, 0, NULL }
+ 	};
+ 
++	/* Postfix unconditionally sets this to 0077 */
++	umask(0027);
++
+ 	CHECKFULLPATH(argv[0]);
+ 
+ 	log_set_name(argv[0]);
+@@ -553,7 +556,7 @@
+                 donemailname = concatstr(3, listdir, "/queue/", randomstr);
+ 
+                 donemailfd = open(donemailname, O_RDWR|O_CREAT|O_EXCL,
+-						S_IRUSR|S_IWUSR);
++						S_IRUSR|S_IWUSR|S_IRGRP);
+ 
+         } while ((donemailfd < 0) && (errno == EEXIST));
diff --git a/fleet/pkgs/overlay.nix b/fleet/pkgs/overlay.nix
new file mode 100644
index 0000000..1f645f0
--- /dev/null
+++ b/fleet/pkgs/overlay.nix
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+final: prev: {
+  cgiserver = final.callPackage ./cgiserver {};
+  declarative-git-repository = final.callPackage ./declarative-git-repository {};
+  public-inbox = final.perlPackages.callPackage ./public-inbox {};
+  public-inbox-init-lite = final.callPackage ./public-inbox-init-lite {};
+
+  # Fixes bundler complaining loudly if $HOME is read-only or unset
+  # Taken from https://github.com/rubygems/rubygems/pull/4724
+  # This is here because the CGit about filter invokes Asciidoctor,
+  # which otherwise causes its log to fill with spurious error messages.
+  # Can be removed once Bundler 2.2.23 or above makes its way into stable.
+  bundler = prev.bundler.overrideAttrs ({ patches ? [], ... }: {
+    patches = patches ++ [ ./permission-warnings-only-when-necessary.patch ];
+    dontBuild = false;
+  });
+
+  # Fixes archives having silly permissions due to Postfix messing with the umask
+  mlmmj = prev.mlmmj.overrideAttrs ({ patches ? [], ... }: {
+    patches = patches ++ [ ./group-readable-archives.patch ];
+  });
+}
diff --git a/fleet/pkgs/permission-warnings-only-when-necessary.patch b/fleet/pkgs/permission-warnings-only-when-necessary.patch
new file mode 100644
index 0000000..4a557a5
--- /dev/null
+++ b/fleet/pkgs/permission-warnings-only-when-necessary.patch
@@ -0,0 +1,50 @@
+SPDX-FileCopyrightText: David Rodríguez <deivid.rodriguez@riseup.net>
+SPDX-License-Identifier: MIT
+--- a/lib/bundler.rb
++++ b/lib/bundler.rb
+@@ -236,8 +236,9 @@ def user_home
+         end
+ 
+         if warning
+-          user_home = tmp_home_path(warning)
+-          Bundler.ui.warn "#{warning}\nBundler will use `#{user_home}' as your home directory temporarily.\n"
++          Bundler.ui.warn "#{warning}\n"
++          user_home = tmp_home_path
++          Bundler.ui.warn "Bundler will use `#{user_home}' as your home directory temporarily.\n"
+           user_home
+         else
+           Pathname.new(home)
+@@ -684,15 +685,13 @@ def configure_gem_home
+       Bundler.rubygems.clear_paths
+     end
+ 
+-    def tmp_home_path(warning)
++    def tmp_home_path
+       Kernel.send(:require, "tmpdir")
+       SharedHelpers.filesystem_access(Dir.tmpdir) do
+         path = Bundler.tmp
+         at_exit { Bundler.rm_rf(path) }
+         path
+       end
+-    rescue RuntimeError => e
+-      raise e.exception("#{warning}\nBundler also failed to create a temporary home directory':\n#{e}")
+     end
+ 
+     # @param env [Hash]
+
+--- a/lib/bundler/settings.rb
++++ b/lib/bundler/settings.rb
+@@ -428,12 +428,8 @@ def printable_value(value, key)
+     def global_config_file
+       if ENV["BUNDLE_CONFIG"] && !ENV["BUNDLE_CONFIG"].empty?
+         Pathname.new(ENV["BUNDLE_CONFIG"])
+-      else
+-        begin
+-          Bundler.user_bundle_path("config")
+-        rescue PermissionError, GenericSystemCallError
+-          nil
+-        end
++      elsif Bundler.rubygems.user_home && !Bundler.rubygems.user_home.empty?
++        Pathname.new(Bundler.rubygems.user_home).join(".bundle/config")
+       end
+     end
diff --git a/fleet/pkgs/public-inbox-init-lite/default.nix b/fleet/pkgs/public-inbox-init-lite/default.nix
new file mode 100644
index 0000000..8704ea3
--- /dev/null
+++ b/fleet/pkgs/public-inbox-init-lite/default.nix
@@ -0,0 +1,18 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+{ lib, substituteAll, public-inbox, runCommand, makeWrapper, git, xapian }:
+
+let
+  perl = public-inbox.fullperl.withPackages
+    (ps: with ps; [ public-inbox URI DBDSQLite SearchXapian ]);
+
+  subbed = substituteAll {
+    src = ./public-inbox-init-lite;
+    isExecutable = true;
+    inherit (perl) interpreter;
+  };
+in runCommand "public-inbox-init-lite" { nativeBuildInputs = [ makeWrapper ]; } ''
+  makeWrapper ${subbed} $out/bin/public-inbox-init-lite \
+    --prefix PATH : ${lib.makeBinPath [ git xapian ]}
+''
diff --git a/fleet/pkgs/public-inbox-init-lite/public-inbox-init-lite b/fleet/pkgs/public-inbox-init-lite/public-inbox-init-lite
new file mode 100644
index 0000000..f6fd560
--- /dev/null
+++ b/fleet/pkgs/public-inbox-init-lite/public-inbox-init-lite
@@ -0,0 +1,60 @@
+#! @interpreter@ -w
+# SPDX-FileCopyrightText: (C) 2014-2021 all contributors <meta@public-inbox.org>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+use strict;
+use v5.10.1;
+use Getopt::Long qw/:config gnu_getopt no_ignore_case auto_abbrev/;
+use Fcntl qw(:DEFAULT);
+
+require PublicInbox::Admin;
+PublicInbox::Admin::require_or_die('-base');
+
+my ($indexlevel, $skip_epoch, $skip_artnum, $jobs, $skip_docdata);
+my %opts = (
+	'indexlevel=s' => \$indexlevel,
+	'skip-epoch=i' => \$skip_epoch,
+	'skip-artnum=i' => \$skip_artnum,
+	'jobs=i' => \$jobs,
+	'skip-docdata' => \$skip_docdata,
+);
+GetOptions(%opts) or exit 1;
+PublicInbox::Admin::indexlevel_ok_or_die($indexlevel) if defined $indexlevel;
+my $name = shift @ARGV or exit 1;
+my $inboxdir = shift @ARGV or exit 1;
+my $primary_address = shift @ARGV or exit 1;
+# TODO(V): Error if any more arguments are passed
+
+$inboxdir = PublicInbox::Config::rel2abs_collapsed($inboxdir);
+die "`\\n' not allowed in `$inboxdir'\n" if index($inboxdir, "\n") >= 0;
+
+if (-d "$inboxdir/objects") {
+	die "$inboxdir is a -V1 inbox\n"
+}
+
+my $ibx = PublicInbox::Inbox->new({
+	inboxdir => $inboxdir,
+	name => $name,
+	version => 2,
+	-primary_address => $primary_address,
+	indexlevel => $indexlevel,
+});
+
+my $creat_opt = {};
+if (defined $jobs) {
+	die "--jobs=$jobs must be >= 1\n" if $jobs <= 0;
+	$creat_opt->{nproc} = $jobs;
+}
+
+require PublicInbox::InboxWritable;
+$ibx = PublicInbox::InboxWritable->new($ibx, $creat_opt);
+if ($skip_docdata) {
+	$ibx->{indexlevel} //= 'full'; # ensure init_inbox writes xdb
+	$ibx->{indexlevel} eq 'basic' and
+		die "--skip-docdata ignored with --indexlevel=basic\n";
+	$ibx->{-skip_docdata} = $skip_docdata;
+}
+$ibx->init_inbox(0, $skip_epoch, $skip_artnum);
+
+require PublicInbox::Spawn;
+PublicInbox::Spawn->import(qw(run_die));
diff --git a/fleet/pkgs/public-inbox/default.nix b/fleet/pkgs/public-inbox/default.nix
new file mode 100644
index 0000000..bb5db29
--- /dev/null
+++ b/fleet/pkgs/public-inbox/default.nix
@@ -0,0 +1,45 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+# TODO(V): Enable highlighting support
+
+{ lib, buildPerlPackage, fetchurl, makeWrapper
+, git, xapian
+, URI, DBDSQLite, SearchXapian, Plack, PlackMiddlewareReverseProxy
+, sqlite  # Only used in tests
+}:
+
+buildPerlPackage rec {
+  pname = "public-inbox";
+  version = "unstable-2021-02-10";
+
+  # We need at least fa3f0cbcd1af5008e56c77e3c46ab60b5eca3a13 for public-inbox-watch to work with mlmmj's archive directory at all.
+  # See also: https://public-inbox.org/meta/CAMwyc-SmvBoVOs+vCMNaWOWPT3TCB-7rJ_0bp43QB+pjzbNv-w@mail.gmail.com/
+  src = fetchurl {
+    url = "https://public-inbox.org/public-inbox.git/snapshot/public-inbox-fa3f0cbcd1af5008e56c77e3c46ab60b5eca3a13.tar.gz";
+    sha256 = "03bynml6gw4936cri31ywqq5ackzkjjggksvpqf220xbcl55w93q";
+  };
+
+  nativeBuildInputs = [ makeWrapper ];
+  buildInputs = [ URI DBDSQLite SearchXapian Plack PlackMiddlewareReverseProxy ];
+
+  checkInputs = [ git sqlite xapian ];
+  # TODO(edef): Only exclude the individual failing tests, not the entire file
+  preCheck = ''
+    rm t/search.t  # Relies on set-gid, which is unavailable in the build sandbox.
+    rm t/spawn.t  # Tries to setpgid to that of pid 1, which (unexpectedly for the test) succeeds in the sandbox.
+  '';
+
+  postFixup = ''
+    for x in $out/bin/*; do
+      wrapProgram $x --prefix PATH : ${lib.makeBinPath [ git xapian ]}
+    done
+  '';
+
+  meta = with lib; {
+    homepage = "https://public-inbox.org/README.html";
+    description = "Git-based mailing-list archive";
+    license = licenses.agpl3Plus;
+    maintainers = with maintainers; [ V ];
+  };
+}
diff --git a/fleet/test b/fleet/test
new file mode 100755
index 0000000..d8e2a87
--- /dev/null
+++ b/fleet/test
@@ -0,0 +1,7 @@
+#! /bin/sh
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+git add . && git commit -m WIP
+git push -f vityaz trunk && ssh vityaz-root nixos-rebuild test --show-trace
+git push -f trieste trunk && ssh trieste-root nixos-rebuild test --show-trace