From ec0965e2672899d25a5a3a8c072de3ea734076a2 Mon Sep 17 00:00:00 2001 From: V Date: Wed, 9 Jun 2021 15:43:16 +0200 Subject: fleet: init Co-authored-by: edef Change-Id: I36d2c4cca542ed91630b1b832f3c7a7b97b33c65 --- fleet/modules/acme.nix | 55 ++++++++ fleet/modules/cgiserver.nix | 73 ++++++++++ fleet/modules/declarative-git.nix | 59 +++++++++ fleet/modules/mail.nix | 70 ++++++++++ fleet/modules/mlmmj.nix | 271 ++++++++++++++++++++++++++++++++++++++ fleet/modules/public-inbox.nix | 134 +++++++++++++++++++ fleet/modules/web.nix | 46 +++++++ 7 files changed, 708 insertions(+) create mode 100644 fleet/modules/acme.nix create mode 100644 fleet/modules/cgiserver.nix create mode 100644 fleet/modules/declarative-git.nix create mode 100644 fleet/modules/mail.nix create mode 100644 fleet/modules/mlmmj.nix create mode 100644 fleet/modules/public-inbox.nix create mode 100644 fleet/modules/web.nix (limited to 'fleet/modules') 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 +# 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 +# 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 +# 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 +# SPDX-FileCopyrightText: edef +# 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 +# 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: " + "List-Help: " + "List-Subscribe: " + "List-Unsubscribe: " + ]; + # 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 +# 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 +# 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 ]; +} -- cgit 1.4.1