From c204541f38b0b065e64b40694fa86f4ffc3d8873 Mon Sep 17 00:00:00 2001 From: niten Date: Mon, 22 Nov 2021 07:23:05 -0800 Subject: [PATCH] Go big or go home --- flake.nix | 2 + lib.nix | 12 +- lib/default.nix | 11 + lib/fudo/acme-certs.nix | 206 ++++ lib/fudo/acme-for-hostname.nix | 69 ++ lib/fudo/authentication.nix | 67 ++ lib/fudo/backplane/common.nix | 154 +++ lib/fudo/backplane/default.nix | 10 + lib/fudo/backplane/dns.nix | 143 +++ lib/fudo/backplane/jabber.nix | 90 ++ lib/fudo/chat.nix | 262 +++++ lib/fudo/client/dns.nix | 131 +++ lib/fudo/common.nix | 5 + lib/fudo/default.nix | 49 + lib/fudo/deploy.nix | 13 + lib/fudo/distributed-builds.nix | 48 + lib/fudo/dns.nix | 178 ++++ lib/fudo/domain/dns.nix | 69 ++ lib/fudo/domain/kerberos.nix | 74 ++ lib/fudo/domains.nix | 94 ++ lib/fudo/garbage-collector.nix | 35 + lib/fudo/git.nix | 171 +++ lib/fudo/global.nix | 5 + lib/fudo/grafana.nix | 143 +++ lib/fudo/host-filesystems.nix | 123 +++ lib/fudo/hosts.nix | 127 +++ lib/fudo/hosts/local-network.nix | 143 +++ lib/fudo/include/rainloop.nix | 117 +++ lib/fudo/initrd-network.nix | 87 ++ lib/fudo/ipfs.nix | 66 ++ lib/fudo/jabber.nix | 236 +++++ lib/fudo/kdc.nix | 532 ++++++++++ lib/fudo/ldap.nix | 460 ++++++++ lib/fudo/local-network.nix | 238 +++++ lib/fudo/mail-container.nix | 221 ++++ lib/fudo/mail.nix | 225 ++++ lib/fudo/mail/clamav.nix | 25 + lib/fudo/mail/dkim.nix | 114 ++ lib/fudo/mail/dovecot.nix | 314 ++++++ .../mail/dovecot/imap_sieve/report-ham.sieve | 15 + .../mail/dovecot/imap_sieve/report-spam.sieve | 7 + .../mail/dovecot/pipe_bin/sa-learn-ham.sh | 3 + .../mail/dovecot/pipe_bin/sa-learn-spam.sh | 3 + lib/fudo/mail/postfix.nix | 319 ++++++ lib/fudo/mail/rspamd.nix | 88 ++ lib/fudo/minecraft-server.nix | 64 ++ lib/fudo/netinfo-email.nix | 93 ++ lib/fudo/networks.nix | 25 + lib/fudo/node-exporter.nix | 60 ++ lib/fudo/nsd.nix | 978 ++++++++++++++++++ lib/fudo/password.nix | 116 +++ lib/fudo/postgres.nix | 370 +++++++ lib/fudo/prometheus.nix | 207 ++++ lib/fudo/secrets.nix | 221 ++++ lib/fudo/secure-dns-proxy.nix | 103 ++ lib/fudo/sites.nix | 240 +++++ lib/fudo/slynk.nix | 70 ++ lib/fudo/ssh.nix | 25 + lib/fudo/system-networking.nix | 168 +++ lib/fudo/system.nix | 500 +++++++++ lib/fudo/users.nix | 126 +++ lib/fudo/vpn.nix | 126 +++ lib/fudo/webmail.nix | 385 +++++++ lib/fudo/wireless-networks.nix | 32 + lib/informis/cl-gemini.nix | 177 ++++ lib/informis/default.nix | 7 + lib/instance.nix | 122 +++ lib/{ => lib}/dns.nix | 0 lib/{ => lib}/filesystem.nix | 0 lib/{ => lib}/ip.nix | 0 lib/{ => lib}/lisp.nix | 0 lib/{ => lib}/network.nix | 0 lib/{ => lib}/passwd.nix | 0 lib/types/host.nix | 305 ++++++ lib/types/network-definition.nix | 108 ++ lib/types/network-host.nix | 32 + lib/types/user.nix | 157 +++ module.nix | 7 + 78 files changed, 10322 insertions(+), 6 deletions(-) create mode 100644 lib/default.nix create mode 100644 lib/fudo/acme-certs.nix create mode 100644 lib/fudo/acme-for-hostname.nix create mode 100644 lib/fudo/authentication.nix create mode 100644 lib/fudo/backplane/common.nix create mode 100644 lib/fudo/backplane/default.nix create mode 100644 lib/fudo/backplane/dns.nix create mode 100644 lib/fudo/backplane/jabber.nix create mode 100644 lib/fudo/chat.nix create mode 100644 lib/fudo/client/dns.nix create mode 100644 lib/fudo/common.nix create mode 100644 lib/fudo/default.nix create mode 100644 lib/fudo/deploy.nix create mode 100644 lib/fudo/distributed-builds.nix create mode 100644 lib/fudo/dns.nix create mode 100644 lib/fudo/domain/dns.nix create mode 100644 lib/fudo/domain/kerberos.nix create mode 100644 lib/fudo/domains.nix create mode 100644 lib/fudo/garbage-collector.nix create mode 100644 lib/fudo/git.nix create mode 100644 lib/fudo/global.nix create mode 100644 lib/fudo/grafana.nix create mode 100644 lib/fudo/host-filesystems.nix create mode 100644 lib/fudo/hosts.nix create mode 100644 lib/fudo/hosts/local-network.nix create mode 100644 lib/fudo/include/rainloop.nix create mode 100644 lib/fudo/initrd-network.nix create mode 100644 lib/fudo/ipfs.nix create mode 100644 lib/fudo/jabber.nix create mode 100644 lib/fudo/kdc.nix create mode 100644 lib/fudo/ldap.nix create mode 100644 lib/fudo/local-network.nix create mode 100644 lib/fudo/mail-container.nix create mode 100644 lib/fudo/mail.nix create mode 100644 lib/fudo/mail/clamav.nix create mode 100644 lib/fudo/mail/dkim.nix create mode 100644 lib/fudo/mail/dovecot.nix create mode 100644 lib/fudo/mail/dovecot/imap_sieve/report-ham.sieve create mode 100644 lib/fudo/mail/dovecot/imap_sieve/report-spam.sieve create mode 100755 lib/fudo/mail/dovecot/pipe_bin/sa-learn-ham.sh create mode 100755 lib/fudo/mail/dovecot/pipe_bin/sa-learn-spam.sh create mode 100644 lib/fudo/mail/postfix.nix create mode 100644 lib/fudo/mail/rspamd.nix create mode 100644 lib/fudo/minecraft-server.nix create mode 100644 lib/fudo/netinfo-email.nix create mode 100644 lib/fudo/networks.nix create mode 100644 lib/fudo/node-exporter.nix create mode 100644 lib/fudo/nsd.nix create mode 100644 lib/fudo/password.nix create mode 100644 lib/fudo/postgres.nix create mode 100644 lib/fudo/prometheus.nix create mode 100644 lib/fudo/secrets.nix create mode 100644 lib/fudo/secure-dns-proxy.nix create mode 100644 lib/fudo/sites.nix create mode 100644 lib/fudo/slynk.nix create mode 100644 lib/fudo/ssh.nix create mode 100644 lib/fudo/system-networking.nix create mode 100644 lib/fudo/system.nix create mode 100644 lib/fudo/users.nix create mode 100644 lib/fudo/vpn.nix create mode 100644 lib/fudo/webmail.nix create mode 100644 lib/fudo/wireless-networks.nix create mode 100644 lib/informis/cl-gemini.nix create mode 100644 lib/informis/default.nix create mode 100644 lib/instance.nix rename lib/{ => lib}/dns.nix (100%) rename lib/{ => lib}/filesystem.nix (100%) rename lib/{ => lib}/ip.nix (100%) rename lib/{ => lib}/lisp.nix (100%) rename lib/{ => lib}/network.nix (100%) rename lib/{ => lib}/passwd.nix (100%) create mode 100644 lib/types/host.nix create mode 100644 lib/types/network-definition.nix create mode 100644 lib/types/network-host.nix create mode 100644 lib/types/user.nix create mode 100644 module.nix diff --git a/flake.nix b/flake.nix index 81e3767..c2620ff 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,8 @@ outputs = { self, ... }: { overlay = import ./overlay.nix; + nixosModule = import ./module.nix; + lib = import ./lib.nix; }; } diff --git a/lib.nix b/lib.nix index 3480596..63bb5e5 100644 --- a/lib.nix +++ b/lib.nix @@ -1,10 +1,10 @@ { pkgs, ... }: { - ip = import ./lib/ip.nix { inherit pkgs; }; - dns = import ./lib/dns.nix { inherit pkgs; }; - passwd = import ./lib/passwd.nix { inherit pkgs; }; - lisp = import ./lib/lisp.nix { inherit pkgs; }; - network = import ./lib/network.nix { inherit pkgs; }; - fs = import ./lib/filesystem.nix { inherit pkgs; }; + ip = import ./lib/lib/ip.nix { inherit pkgs; }; + dns = import ./lib/lib/dns.nix { inherit pkgs; }; + passwd = import ./lib/lib/passwd.nix { inherit pkgs; }; + lisp = import ./lib/lib/lisp.nix { inherit pkgs; }; + network = import ./lib/lib/network.nix { inherit pkgs; }; + fs = import ./lib/lib/filesystem.nix { inherit pkgs; }; } diff --git a/lib/default.nix b/lib/default.nix new file mode 100644 index 0000000..c50fd3a --- /dev/null +++ b/lib/default.nix @@ -0,0 +1,11 @@ +{ lib, config, pkgs, ... }: + +{ + imports = [ + ./instance.nix + + ./fudo + + ./informis + ]; +} diff --git a/lib/fudo/acme-certs.nix b/lib/fudo/acme-certs.nix new file mode 100644 index 0000000..c11627e --- /dev/null +++ b/lib/fudo/acme-certs.nix @@ -0,0 +1,206 @@ +{ config, lib, pkgs, ... } @ toplevel: + +with lib; +let + hostname = config.instance.hostname; + + domainOpts = { name, ... }: let + domain = name; + in { + options = with types; { + email = mkOption { + type = str; + description = "Domain administrator email."; + default = "admin@${domain}"; + }; + + extra-domains = mkOption { + type = listOf str; + description = "List of domains to add to this certificate."; + default = []; + }; + + local-copies = let + localCopyOpts = { name, ... }: let + copy = name; + in { + options = with types; let + target-path = "/run/ssl-certificates/${domain}/${copy}"; + in { + user = mkOption { + type = str; + description = "User to which this copy belongs."; + }; + + group = mkOption { + type = nullOr str; + description = "Group to which this copy belongs."; + default = null; + }; + + service = mkOption { + type = str; + description = "systemd job to copy certs."; + default = "fudo-acme-${domain}-${copy}-certs.service"; + }; + + certificate = mkOption { + type = str; + description = "Full path to the local copy certificate."; + default = "${target-path}/cert.pem"; + }; + + full-certificate = mkOption { + type = str; + description = "Full path to the local copy certificate."; + default = "${target-path}/fullchain.pem"; + }; + + chain = mkOption { + type = str; + description = "Full path to the local copy certificate."; + default = "${target-path}/chain.pem"; + }; + + private-key = mkOption { + type = str; + description = "Full path to the local copy certificate."; + default = "${target-path}/key.pem"; + }; + + dependent-services = mkOption { + type = listOf str; + description = "List of systemd services depending on this copy."; + default = [ ]; + }; + + part-of = mkOption { + type = listOf str; + description = "List of systemd targets to which this copy belongs."; + default = [ ]; + }; + }; + }; + in mkOption { + type = attrsOf (submodule localCopyOpts); + description = "Map of copies to make for use by services."; + default = {}; + }; + }; + }; + + head-or-null = lst: if (lst == []) then null else head lst; + rm-service-ext = filename: + head-or-null (builtins.match "^(.+)\.service$" filename); + + concatMapAttrs = f: attrs: + foldr (a: b: a // b) {} (mapAttrsToList f attrs); + + cfg = config.fudo.acme; + hasLocalDomains = hasAttr hostname cfg.host-domains; + localDomains = if hasLocalDomains then + cfg.host-domains.${hostname} else {}; + + optionalStringOr = str: default: + if (str != null) then str else default; + +in { + options.fudo.acme = with types; { + host-domains = mkOption { + type = attrsOf (attrsOf (submodule domainOpts)); + description = "Map of host to domains to domain options."; + default = { }; + }; + }; + + config = { + security.acme.certs = mapAttrs (domain: domainOpts: { + email = domainOpts.email; + extraDomainNames = domainOpts.extra-domains; + }) localDomains; + + # Assume that if we're acquiring SSL certs, we have a real IP for the + # host. nginx must have an acme dir for security.acme to work. + services.nginx = mkIf hasLocalDomains { + enable = true; + + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedTlsSettings = true; + recommendedProxySettings = true; + + virtualHosts.${config.instance.host-fqdn} = { + enableACME = true; + forceSSL = true; + + # Just...force override if you want this to point somewhere. + locations."/" = { + return = "403 Forbidden"; + }; + }; + }; + + networking.firewall.allowedTCPPorts = [ 80 443 ]; + + systemd = { + tmpfiles.rules = let + copies = concatMapAttrs (domain: domainOpts: + domainOpts.local-copies) localDomains; + perms = copyOpts: if (copyOpts.group != null) then "0550" else "0500"; + copy-paths = mapAttrsToList (copy: copyOpts: + let + dir-entry = copyOpts: file: "d \"${dirOf file}\" ${perms copyOpts} ${copyOpts.user} ${optionalStringOr copyOpts.group "-"} - -"; + in map (dir-entry copyOpts) [ + copyOpts.certificate + copyOpts.full-certificate + copyOpts.chain + copyOpts.private-key + ]) copies; + in unique (concatMap (i: unique i) copy-paths); + + services = concatMapAttrs (domain: domainOpts: + concatMapAttrs (copy: copyOpts: let + key-perms = copyOpts: if (copyOpts.group != null) then "0440" else "0400"; + source = config.security.acme.certs.${domain}.directory; + target = copyOpts.path; + owners = + if (copyOpts.group != null) then + "${copyOpts.user}:${copyOpts.group}" + else copyOpts.user; + install-certs = pkgs.writeShellScript "fudo-install-${domain}-${copy}-certs.sh" '' + cp ${source}/cert.pem ${copyOpts.certificate} + chmod 0444 ${copyOpts.certificate} + chown ${owners} ${copyOpts.certificate} + + cp ${source}/full.pem ${copyOpts.full-certificate} + chmod 0444 ${copyOpts.full-certificate} + chown ${owners} ${copyOpts.full-certificate} + + cp ${source}/chain.pem ${copyOpts.chain} + chmod 0444 ${copyOpts.chain} + chown ${owners} ${copyOpts.chain} + + cp ${source}/key.pem ${copyOpts.private-key} + chmod ${key-perms copyOpts} ${copyOpts.private-key} + chown ${owners} ${copyOpts.private-key} + ''; + + service-name = rm-service-ext copyOpts.service; + in { + ${service-name} = { + description = "Copy ${domain} ACME certs for ${copy}."; + after = [ "acme-${domain}.service" ]; + before = copyOpts.dependent-services; + wantedBy = [ "multi-user.target" ] ++ copyOpts.dependent-services; + partOf = copyOpts.part-of; + serviceConfig = { + Type = "simple"; + ExecStart = install-certs; + RemainAfterExit = true; + StandardOutput = "journal"; + }; + }; + }) domainOpts.local-copies) localDomains; + }; + }; +} diff --git a/lib/fudo/acme-for-hostname.nix b/lib/fudo/acme-for-hostname.nix new file mode 100644 index 0000000..0451170 --- /dev/null +++ b/lib/fudo/acme-for-hostname.nix @@ -0,0 +1,69 @@ +# Starts an Nginx server on $HOSTNAME just to get a cert for this host + +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.acme; + + # wwwRoot = hostname: + # pkgs.writeTextFile { + # name = "index.html"; + + # text = '' + # + # + # ${hostname} + # + # + #

${hostname} + # + # + # ''; + # destination = "/www"; + # }; + +in { + + options.fudo.acme = { + enable = mkEnableOption "Fetch ACME certs for supplied local hostnames."; + + hostnames = mkOption { + type = with types; listOf str; + description = "A list of hostnames mapping to this host, for which to acquire SSL certificates."; + default = []; + example = [ + "my.hostname.com" + "alt.hostname.com" + ]; + }; + + admin-address = mkOption { + type = types.str; + description = "The admin address in charge of these addresses."; + default = "admin@fudo.org"; + }; + }; + + config = mkIf cfg.enable { + + services.nginx = { + enable = true; + + virtualHosts = listToAttrs + (map + (hostname: + nameValuePair hostname + { + enableACME = true; + forceSSL = true; + # root = (wwwRoot hostname) + ("/" + "www"); + }) + cfg.hostnames); + }; + + security.acme.certs = listToAttrs + (map (hostname: nameValuePair hostname { email = cfg.admin-address; }) + cfg.hostnames); + }; +} diff --git a/lib/fudo/authentication.nix b/lib/fudo/authentication.nix new file mode 100644 index 0000000..c88fa7c --- /dev/null +++ b/lib/fudo/authentication.nix @@ -0,0 +1,67 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.authentication; +in { + options.fudo.authentication = { + enable = mkEnableOption "Use Fudo users & groups from LDAP."; + + ssl-ca-certificate = mkOption { + type = types.str; + description = "Path to the CA certificate to use to bind to the server."; + }; + + bind-passwd-file = mkOption { + type = types.str; + description = "Path to a file containing the password used to bind to the server."; + }; + + ldap-url = mkOption { + type = types.str; + description = "URL of the LDAP server."; + example = "ldaps://auth.fudo.org"; + }; + + base = mkOption { + type = types.str; + description = "The LDAP base in which to look for users."; + default = "dc=fudo,dc=org"; + }; + + bind-dn = mkOption { + type = types.str; + description = "The DN with which to bind the LDAP server."; + default = "cn=auth_reader,dc=fudo,dc=org"; + }; + }; + + config = mkIf cfg.enable { + users.ldap = { + enable = true; + base = cfg.base; + bind = { + distinguishedName = cfg.bind-dn; + passwordFile = cfg.bind-passwd-file; + timeLimit = 5; + }; + loginPam = true; + nsswitch = true; + server = cfg.ldap-url; + timeLimit = 5; + useTLS = true; + extraConfig = '' + TLS_CACERT ${cfg.ssl-ca-certificate} + TSL_REQCERT allow + ''; + + daemon = { + enable = true; + extraConfig = '' + tls_cacertfile ${cfg.ssl-ca-certificate} + tls_reqcert allow + ''; + }; + }; + }; +} diff --git a/lib/fudo/backplane/common.nix b/lib/fudo/backplane/common.nix new file mode 100644 index 0000000..a1d3e95 --- /dev/null +++ b/lib/fudo/backplane/common.nix @@ -0,0 +1,154 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.backplane.dns; + + powerdns-conf-dir = "${cfg.powerdns.home}/conf.d"; + + clientHostOpts = { name, ... }: { + options = with types; { + password-file = mkOption { + type = path; + description = + "Location (on the build host) of the file containing the host password."; + }; + }; + }; + + serviceOpts = { name, ... }: { + options = with types; { + password-file = mkOption { + type = path; + description = + "Location (on the build host) of the file containing the service password."; + }; + }; + }; + + databaseOpts = { ... }: { + options = with types; { + host = mkOption { + type = str; + description = "Hostname or IP of the PostgreSQL server."; + }; + + database = mkOption { + type = str; + description = "Database to use for DNS backplane."; + default = "backplane_dns"; + }; + + username = mkOption { + type = str; + description = "Database user for DNS backplane."; + default = "backplane_dns"; + }; + + password-file = mkOption { + type = str; + description = "File containing password for database user."; + }; + }; + }; + +in { + options.fudo.backplane = with types; { + + client-hosts = mkOption { + type = attrsOf (submodule clientHostOpts); + description = "List of backplane client options."; + default = {}; + }; + + services = mkOption { + type = attrsOf (submodule serviceOpts); + description = "List of backplane service options."; + default = {}; + }; + + backplane-host = mkOption { + type = types.str; + description = "Hostname of the backplane XMPP server."; + }; + + dns = { + enable = mkEnableOption "Enable backplane dynamic DNS server."; + + port = mkOption { + type = port; + description = "Port on which to serve authoritative DNS requests."; + default = 53; + }; + + listen-v4-addresses = mkOption { + type = listOf str; + description = "IPv4 addresses on which to listen for dns requests."; + default = [ "0.0.0.0" ]; + }; + + listen-v6-addresses = mkOption { + type = listOf str; + description = "IPv6 addresses on which to listen for dns requests."; + example = [ "[abcd::1]" ]; + default = [ ]; + }; + + required-services = mkOption { + type = listOf str; + description = + "A list of services required before the DNS server can start."; + default = [ ]; + }; + + user = mkOption { + type = str; + description = "User as which to run DNS backplane listener service."; + default = "backplane-dns"; + }; + + group = mkOption { + type = str; + description = "Group as which to run DNS backplane listener service."; + default = "backplane-dns"; + }; + + database = mkOption { + type = submodule databaseOpts; + description = "Database settings for the DNS server."; + }; + + powerdns = { + home = mkOption { + type = str; + description = "Directory at which to store powerdns configuration and state."; + default = "/run/backplane-dns/powerdns"; + }; + + user = mkOption { + type = str; + description = "Username as which to run PowerDNS."; + default = "backplane-powerdns"; + }; + + database = mkOption { + type = submodule databaseOpts; + description = "Database settings for the DNS server."; + }; + }; + + backplane-role = { + role = mkOption { + type = types.str; + description = "Backplane XMPP role name for the DNS server."; + default = "service-dns"; + }; + + password-file = mkOption { + type = types.str; + description = "File containing XMPP password for backplane role."; + }; + }; + }; + }; +} diff --git a/lib/fudo/backplane/default.nix b/lib/fudo/backplane/default.nix new file mode 100644 index 0000000..5596440 --- /dev/null +++ b/lib/fudo/backplane/default.nix @@ -0,0 +1,10 @@ +{ config, pkgs, lib, ... }: + +with lib; +{ + imports = [ + ./common.nix + ./dns.nix + ./jabber.nix + ]; +} diff --git a/lib/fudo/backplane/dns.nix b/lib/fudo/backplane/dns.nix new file mode 100644 index 0000000..6c97556 --- /dev/null +++ b/lib/fudo/backplane/dns.nix @@ -0,0 +1,143 @@ +{ config, pkgs, lib, ... }: + +with lib; +let + backplane-cfg = config.fudo.backplane; + + cfg = backplane-cfg.dns; + + powerdns-conf-dir = "${cfg.powerdns.home}/conf.d"; + +in { + config = mkIf cfg.enable { + users = { + users = { + "${cfg.user}" = { + isSystemUser = true; + group = cfg.group; + createHome = true; + home = "/var/home/${cfg.user}"; + }; + ${cfg.powerdns.user} = { + isSystemUser = true; + home = cfg.powerdns.home; + createHome = true; + }; + }; + + groups = { + ${cfg.group} = { members = [ cfg.user ]; }; + ${cfg.powerdns.user} = { members = [ cfg.powerdns.user ]; }; + }; + }; + + fudo = { + system.services = { + backplane-powerdns-config-generator = { + description = + "Generate postgres configuration for backplane DNS server."; + requires = cfg.required-services; + type = "oneshot"; + restartIfChanged = true; + partOf = [ "backplane-dns.target" ]; + + readWritePaths = [ powerdns-conf-dir ]; + + # This builds the config in a bash script, to avoid storing the password + # in the nix store at any point + script = let + user = cfg.powerdns.user; + db = cfg.powerdns.database; + in '' + TMPDIR=$(${pkgs.coreutils}/bin/mktemp -d -t pdns-XXXXXXXXXX) + TMPCONF=$TMPDIR/pdns.local.gpgsql.conf + + if [ ! -f ${cfg.database.password-file} ]; then + echo "${cfg.database.password-file} does not exist!" + exit 1 + fi + + touch $TMPCONF + chmod go-rwx $TMPCONF + chown ${user} $TMPCONF + PASSWORD=$(cat ${db.password-file}) + echo "launch+=gpgsql" >> $TMPCONF + echo "gpgsql-host=${db.host}" >> $TMPCONF + echo "gpgsql-dbname=${db.database}" >> $TMPCONF + echo "gpgsql-user=${db.username}" >> $TMPCONF + echo "gpgsql-password=$PASSWORD" >> $TMPCONF + echo "gpgsql-dnssec=yes" >> $TMPCONF + + mv $TMPCONF ${powerdns-conf-dir}/pdns.local.gpgsql.conf + rm -rf $TMPDIR + + exit 0 + ''; + }; + + backplane-dns = { + description = "Fudo DNS Backplane Server"; + restartIfChanged = true; + path = with pkgs; [ backplane-dns-server ]; + execStart = "launch-backplane-dns.sh"; + pidFile = "/run/backplane-dns.$USERNAME.pid"; + user = cfg.user; + group = cfg.group; + partOf = [ "backplane-dns.target" ]; + requires = cfg.required-services ++ [ "postgresql.service" ]; + environment = { + FUDO_DNS_BACKPLANE_XMPP_HOSTNAME = backplane-cfg.backplane-host; + FUDO_DNS_BACKPLANE_XMPP_USERNAME = cfg.backplane-role.role; + FUDO_DNS_BACKPLANE_XMPP_PASSWORD_FILE = cfg.backplane-role.password-file; + FUDO_DNS_BACKPLANE_DATABASE_HOSTNAME = cfg.database.host; + FUDO_DNS_BACKPLANE_DATABASE_NAME = cfg.database.database; + FUDO_DNS_BACKPLANE_DATABASE_USERNAME = + cfg.database.username; + FUDO_DNS_BACKPLANE_DATABASE_PASSWORD_FILE = + cfg.database.password-file; + + CL_SOURCE_REGISTRY = + pkgs.lib.fudo.lisp.lisp-source-registry pkgs.backplane-dns-server; + }; + }; + }; + }; + + systemd = { + tmpfiles.rules = [ + "d ${powerdns-conf-dir} 0700 ${cfg.powerdns.user} - - -" + ]; + + targets = { + backplane-dns = { + description = "Fudo DNS backplane services."; + wantedBy = [ "multi-user.target" ]; + after = cfg.required-services ++ [ "postgresql.service" ]; + }; + }; + + services = { + backplane-powerdns = let + pdns-config-dir = pkgs.writeTextDir "pdns.conf" '' + local-address=${lib.concatStringsSep ", " cfg.listen-v4-addresses} + local-ipv6=${lib.concatStringsSep ", " cfg.listen-v6-addresses} + local-port=${toString cfg.port} + launch= + include-dir=${powerdns-conf-dir}/ + ''; + in { + description = "Backplane PowerDNS name server"; + requires = [ + "postgresql.service" + "backplane-powerdns-config-generator.service" + ]; + after = [ "network.target" ]; + path = with pkgs; [ powerdns postgresql ]; + serviceConfig = { + ExecStart = "pdns_server --setuid=${cfg.powerdns.user} --setgid=${cfg.powerdns.user} --chroot=${cfg.powerdns.home} --socket-dir=/ --daemon=no --guardian=no --disable-syslog --write-pid=no --config-dir=${pdns-config-dir}"; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/backplane/jabber.nix b/lib/fudo/backplane/jabber.nix new file mode 100644 index 0000000..8f6e988 --- /dev/null +++ b/lib/fudo/backplane/jabber.nix @@ -0,0 +1,90 @@ +{ config, lib, pkgs, ... }: + +with lib; +{ + config = mkIf config.fudo.jabber.enable { + fudo = let + cfg = config.fudo.backplane; + + hostname = config.instance.hostname; + + backplane-server = cfg.backplane-host; + + generate-auth-file = name: files: let + make-entry = name: passwd-file: + ''("${name}" . "${readFile passwd-file}")''; + entries = mapAttrsToList make-entry files; + content = concatStringsSep "\n" entries; + in pkgs.writeText "${name}-backplane-auth.scm" "'(${content})"; + + host-auth-file = generate-auth-file "host" + (mapAttrs (hostname: hostOpts: hostOpts.password-file) + cfg.client-hosts); + + service-auth-file = generate-auth-file "service" + (mapAttrs (service: serviceOpts: serviceOpts.password-file) + cfg.services); + + in { + secrets.host-secrets.${hostname} = { + backplane-host-auth = { + source-file = host-auth-file; + target-file = "/var/backplane/host-passwords.scm"; + user = config.fudo.jabber.user; + }; + backplane-service-auth = { + source-file = service-auth-file; + target-file = "/var/backplane/service-passwords.scm"; + user = config.fudo.jabber.user; + }; + }; + + jabber = { + environment = { + FUDO_HOST_PASSWD_FILE = + secrets.backplane-host-auth.target-file; + FUDO_SERVICE_PASSWD_FILE = + secrets.backplane-service-auth.target-file; + }; + + sites.${backplane-server} = { + site-config = { + auth_method = "external"; + extauth_program = + "${pkgs.guile}/bin/guile -s ${pkgs.backplane-auth}/backplane-auth.scm"; + extauth_pool_size = 3; + auth_use_cache = true; + + modules = { + mod_adhoc = {}; + mod_caps = {}; + mod_carboncopy = {}; + mod_client_state = {}; + mod_configure = {}; + mod_disco = {}; + mod_fail2ban = {}; + mod_last = {}; + mod_offline = { + access_max_user_messages = 5000; + }; + mod_ping = {}; + mod_pubsub = { + access_createnode = "pubsub_createnode"; + ignore_pep_from_offline = true; + last_item_cache = false; + plugins = [ + "flat" + "pep" + ]; + }; + mod_roster = {}; + mod_stream_mgmt = {}; + mod_time = {}; + mod_version = {}; + }; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/chat.nix b/lib/fudo/chat.nix new file mode 100644 index 0000000..b885373 --- /dev/null +++ b/lib/fudo/chat.nix @@ -0,0 +1,262 @@ +{ pkgs, lib, config, ... }: + +with lib; +let + cfg = config.fudo.chat; + mattermost-config-target = "/run/chat/mattermost/mattermost-config.json"; + +in { + options.fudo.chat = with types; { + enable = mkEnableOption "Enable chat server"; + + hostname = mkOption { + type = str; + description = "Hostname at which this chat server is accessible."; + example = "chat.mydomain.com"; + }; + + site-name = mkOption { + type = str; + description = "The name of this chat server."; + example = "My Fancy Chat Site"; + }; + + smtp = { + server = mkOption { + type = str; + description = "SMTP server to use for sending notification emails."; + example = "mail.my-site.com"; + }; + + user = mkOption { + type = str; + description = "Username with which to connect to the SMTP server."; + }; + + password-file = mkOption { + type = str; + description = + "Path to a file containing the password to use while connecting to the SMTP server."; + }; + }; + + state-directory = mkOption { + type = str; + description = "Path at which to store server state data."; + default = "/var/lib/mattermost"; + }; + + database = mkOption { + type = (submodule { + options = { + name = mkOption { + type = str; + description = "Database name."; + }; + + hostname = mkOption { + type = str; + description = "Database host."; + }; + + user = mkOption { + type = str; + description = "Database user."; + }; + + password-file = mkOption { + type = str; + description = "Path to file containing database password."; + }; + }; + }); + description = "Database configuration."; + example = { + name = "my_database"; + hostname = "my.database.com"; + user = "db_user"; + password-file = /path/to/some/file.pw; + }; + }; + }; + + config = mkIf cfg.enable (let + pkg = pkgs.mattermost; + default-config = builtins.fromJSON (readFile "${pkg}/config/config.json"); + modified-config = recursiveUpdate default-config { + ServiceSettings.SiteURL = "https://${cfg.hostname}"; + ServiceSettings.ListenAddress = "127.0.0.1:8065"; + TeamSettings.SiteName = cfg.site-name; + EmailSettings = { + RequireEmailVerification = true; + SMTPServer = cfg.smtp.server; + SMTPPort = 587; + EnableSMTPAuth = true; + SMTPUsername = cfg.smtp.user; + SMTPPassword = "__SMTP_PASSWD__"; + SendEmailNotifications = true; + ConnectionSecurity = "STARTTLS"; + FeedbackEmail = "chat@fudo.org"; + FeedbackName = "Admin"; + }; + EnableEmailInvitations = true; + SqlSettings.DriverName = "postgres"; + SqlSettings.DataSource = "postgres://${ + cfg.database.user + }:__DATABASE_PASSWORD__@${ + cfg.database.hostname + }:5432/${ + cfg.database.name + }"; + }; + mattermost-config-file-template = + pkgs.writeText "mattermost-config.json.template" (builtins.toJSON modified-config); + mattermost-user = "mattermost"; + mattermost-group = "mattermost"; + + generate-mattermost-config = target: template: smtp-passwd-file: db-passwd-file: + pkgs.writeScript "mattermost-config-generator.sh" '' + SMTP_PASSWD=$( cat ${smtp-passwd-file} ) + DATABASE_PASSWORD=$( cat ${db-passwd-file} ) + sed -e 's/__SMTP_PASSWD__/"$SMTP_PASSWD"/' -e 's/__DATABASE_PASSWORD__/"$DATABASE_PASSWORD"/' ${template} > ${target} + ''; + + in { + users = { + users = { + ${mattermost-user} = { + isSystemUser = true; + group = mattermost-group; + }; + }; + + groups = { ${mattermost-group} = { members = [ mattermost-user ]; }; }; + }; + + fudo.system.services.mattermost = { + description = "Mattermost Chat Server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + preStart = '' + ${generate-mattermost-config + mattermost-config-target + mattermost-config-file-template + cfg.smtp.password-file + cfg.database.password-file} + cp ${cfg.smtp.password-file} ${cfg.state-directory}/config/config.json + cp -uRL ${pkg}/client ${cfg.state-directory} + chown ${mattermost-user}:${mattermost-group} ${cfg.state-directory}/client + chmod 0750 ${cfg.state-directory}/client + ''; + execStart = "${pkg}/bin/mattermost"; + workingDirectory = cfg.state-directory; + user = mattermost-user; + group = mattermost-group; + }; + + systemd = { + + tmpfiles.rules = [ + "d ${cfg.state-directory} 0750 ${mattermost-user} ${mattermost-group} - -" + "d ${cfg.state-directory}/config 0750 ${mattermost-user} ${mattermost-group} - -" + "L ${cfg.state-directory}/bin - - - - ${pkg}/bin" + "L ${cfg.state-directory}/fonts - - - - ${pkg}/fonts" + "L ${cfg.state-directory}/i18n - - - - ${pkg}/i18n" + "L ${cfg.state-directory}/templates - - - - ${pkg}/templates" + ]; + + # services.mattermost = { + # description = "Mattermost Chat Server"; + # wantedBy = [ "multi-user.target" ]; + # after = [ "network.target" ]; + + # preStart = '' + # ${generate-mattermost-config + # mattermost-config-target + # mattermost-config-file-template + # cfg.smtp.password-file + # cfg.database.password-file} + # cp ${cfg.smtp.password-file} ${cfg.state-directory}/config/config.json + # cp -uRL ${pkg}/client ${cfg.state-directory} + # chown ${mattermost-user}:${mattermost-group} ${cfg.state-directory}/client + # chmod 0750 ${cfg.state-directory}/client + # ''; + + # serviceConfig = { + # PermissionsStartOnly = true; + # ExecStart = "${pkg}/bin/mattermost"; + # WorkingDirectory = cfg.state-directory; + # Restart = "always"; + # RestartSec = "10"; + # LimitNOFILE = "49152"; + # User = mattermost-user; + # Group = mattermost-group; + # }; + # }; + }; + + services.nginx = { + enable = true; + + appendHttpConfig = '' + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mattermost_cache:10m max_size=3g inactive=120m use_temp_path=off; + ''; + + virtualHosts = { + "${cfg.hostname}" = { + enableACME = true; + forceSSL = true; + + locations."/" = { + proxyPass = "http://127.0.0.1:8065"; + + extraConfig = '' + client_max_body_size 50M; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Frame-Options SAMEORIGIN; + proxy_buffers 256 16k; + proxy_buffer_size 16k; + proxy_read_timeout 600s; + proxy_cache mattermost_cache; + proxy_cache_revalidate on; + proxy_cache_min_uses 2; + proxy_cache_use_stale timeout; + proxy_cache_lock on; + proxy_http_version 1.1; + ''; + }; + + locations."~ /api/v[0-9]+/(users/)?websocket$" = { + proxyPass = "http://127.0.0.1:8065"; + + extraConfig = '' + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + client_max_body_size 50M; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Frame-Options SAMEORIGIN; + proxy_buffers 256 16k; + proxy_buffer_size 16k; + client_body_timeout 60; + send_timeout 300; + lingering_timeout 5; + proxy_connect_timeout 90; + proxy_send_timeout 300; + proxy_read_timeout 90s; + ''; + }; + }; + }; + }; + }); +} diff --git a/lib/fudo/client/dns.nix b/lib/fudo/client/dns.nix new file mode 100644 index 0000000..c81292e --- /dev/null +++ b/lib/fudo/client/dns.nix @@ -0,0 +1,131 @@ +{ config, pkgs, lib, ... }: + +with lib; +let + cfg = config.fudo.client.dns; + + ssh-key-files = + map (host-key: host-key.path) config.services.openssh.hostKeys; + + ssh-key-args = concatStringsSep " " (map (file: "-f ${file}") ssh-key-files); + +in { + options.fudo.client.dns = { + ipv4 = mkOption { + type = types.bool; + default = true; + description = "Report host external IPv4 address to Fudo DynDNS server."; + }; + + ipv6 = mkOption { + type = types.bool; + default = true; + description = "Report host external IPv6 address to Fudo DynDNS server."; + }; + + sshfp = mkOption { + type = types.bool; + default = true; + description = "Report host SSH fingerprints to the Fudo DynDNS server."; + }; + + domain = mkOption { + type = types.str; + description = "Domain under which this host is registered."; + default = "fudo.link"; + }; + + server = mkOption { + type = types.str; + description = "Backplane DNS server to which changes will be reported."; + default = "backplane.fudo.org"; + }; + + password-file = mkOption { + type = types.str; + description = "File containing host password for backplane."; + example = "/path/to/secret.passwd"; + }; + + frequency = mkOption { + type = types.str; + description = + "Frequency at which to report the local IP(s) to backplane."; + default = "*:0/15"; + }; + + user = mkOption { + type = types.str; + description = + "User as which to run the client script (must have access to password file)."; + default = "backplane-dns-client"; + }; + + external-interface = mkOption { + type = with types; nullOr str; + description = + "Interface with which this host communicates with the larger internet."; + default = null; + }; + }; + + config = { + + users.users = { + "${cfg.user}" = { + isSystemUser = true; + createHome = true; + home = "/var/home/${cfg.user}"; + }; + }; + + systemd = { + tmpfiles.rules = [ + "d /var/home 755 root - - -" + "d /var/home/${cfg.user} 700 ${cfg.user} - - -" + ]; + + timers.backplane-dns-client = { + enable = true; + description = "Report local IP addresses to Fudo backplane."; + partOf = [ "backplane-dns-client.service" ]; + wantedBy = [ "timers.target" ]; + requires = [ "network-online.target" ]; + timerConfig = { OnCalendar = cfg.frequency; }; + }; + + services.backplane-dns-client-pw-file = { + enable = true; + requiredBy = [ "backplane-dns-client.services" ]; + reloadIfChanged = true; + serviceConfig = { Type = "oneshot"; }; + script = '' + chmod 400 ${cfg.password-file} + chown ${cfg.user} ${cfg.password-file} + ''; + }; + + services.backplane-dns-client = { + enable = true; + serviceConfig = { + Type = "oneshot"; + StandardOutput = "journal"; + User = cfg.user; + ExecStart = pkgs.writeShellScript "start-backplane-dns-client.sh" '' + ${pkgs.backplane-dns-client}/bin/backplane-dns-client ${ + optionalString cfg.ipv4 "-4" + } ${optionalString cfg.ipv6 "-6"} ${ + optionalString cfg.sshfp ssh-key-args + } ${ + optionalString (cfg.external-interface != null) + "--interface=${cfg.external-interface}" + } --domain=${cfg.domain} --server=${cfg.server} --password-file=${cfg.password-file} + ''; + }; + # Needed to generate SSH fingerprinst + path = [ pkgs.openssh ]; + reloadIfChanged = true; + }; + }; + }; +} diff --git a/lib/fudo/common.nix b/lib/fudo/common.nix new file mode 100644 index 0000000..92b7bb7 --- /dev/null +++ b/lib/fudo/common.nix @@ -0,0 +1,5 @@ +# General Fudo config, shared across packages +{ config, lib, pkgs, ... }: + +with lib; +{ } diff --git a/lib/fudo/default.nix b/lib/fudo/default.nix new file mode 100644 index 0000000..ac92be9 --- /dev/null +++ b/lib/fudo/default.nix @@ -0,0 +1,49 @@ +{ config, lib, pkgs, ... }: + +with lib; { + imports = [ + ./acme-certs.nix + ./acme-for-hostname.nix + ./authentication.nix + ./backplane + ./chat.nix + ./client/dns.nix + ./deploy.nix + ./distributed-builds.nix + ./dns.nix + ./domains.nix + ./garbage-collector.nix + ./git.nix + ./global.nix + ./grafana.nix + ./hosts.nix + ./host-filesystems.nix + ./initrd-network.nix + ./ipfs.nix + ./jabber.nix + ./kdc.nix + ./ldap.nix + ./local-network.nix + ./mail.nix + ./mail-container.nix + ./minecraft-server.nix + ./netinfo-email.nix + ./networks.nix + ./node-exporter.nix + ./nsd.nix + ./password.nix + ./postgres.nix + ./prometheus.nix + ./secrets.nix + ./secure-dns-proxy.nix + ./sites.nix + ./slynk.nix + ./ssh.nix + ./system.nix + ./system-networking.nix + ./users.nix + ./vpn.nix + ./webmail.nix + ./wireless-networks.nix + ]; +} diff --git a/lib/fudo/deploy.nix b/lib/fudo/deploy.nix new file mode 100644 index 0000000..522ce4d --- /dev/null +++ b/lib/fudo/deploy.nix @@ -0,0 +1,13 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + site-cfg = config.fudo.sites.${config.instance.local-site}; + +in { + config = { + users.users.root.openssh.authorizedKeys.keys = + mkIf (site-cfg.deploy-pubkeys != null) + site-cfg.deploy-pubkeys; + }; +} diff --git a/lib/fudo/distributed-builds.nix b/lib/fudo/distributed-builds.nix new file mode 100644 index 0000000..ab21008 --- /dev/null +++ b/lib/fudo/distributed-builds.nix @@ -0,0 +1,48 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + + site-cfg = config.fudo.sites.${config.instance.local-site}; + + has-build-servers = (length (attrNames site-cfg.build-servers)) > 0; + + build-keypair = config.fudo.secrets.host-secrets.${hostname}.build-keypair; + + enable-distributed-builds = + site-cfg.enable-distributed-builds && has-build-servers && build-keypair != null; + + local-build-cfg = if (hasAttr hostname site-cfg.build-servers) then + site-cfg.build-servers.${hostname} + else null; + +in { + config = { + nix = mkIf enable-distributed-builds { + buildMachines = mapAttrsToList (hostname: buildOpts: { + hostName = "${hostname}.${domain-name}"; + maxJobs = buildOpts.max-jobs; + speedFactor = buildOpts.speed-factor; + supportedFeatures = buildOpts.supportedFeatures; + sshKey = build-keypair.private-key; + sshUser = buildOpts.user; + }) site-cfg.build-servers; + distributedBuilds = true; + + trustedUsers = mkIf (local-build-cfg != null) [ + local-build-host.build-user + ]; + }; + + users.users = mkIf (local-build-cfg != null) { + ${local-build-cfg.build-user} = { + isSystemUser = true; + openssh.authorizedKeys.keyFiles = + concatLists + (mapAttrsToList (host: hostOpts: hostOpts.build-pubkeys) + config.instance.local-hosts); + }; + }; + }; +} diff --git a/lib/fudo/dns.nix b/lib/fudo/dns.nix new file mode 100644 index 0000000..fcee95b --- /dev/null +++ b/lib/fudo/dns.nix @@ -0,0 +1,178 @@ +{ lib, config, pkgs, ... }: + +with lib; +let + cfg = config.fudo.dns; + + join-lines = concatStringsSep "\n"; + + domainOpts = { domain, ... }: { + options = with types; { + dnssec = mkOption { + type = bool; + description = "Enable DNSSEC security for this zone."; + default = true; + }; + + dmarc-report-address = mkOption { + type = nullOr str; + description = "The email to use to recieve DMARC reports, if any."; + example = "admin-user@domain.com"; + default = null; + }; + + network-definition = mkOption { + type = submodule (import ../types/network-definition.nix); + description = "Definition of network to be served by local server."; + }; + + default-host = mkOption { + type = str; + description = "The host to which the domain should map by default."; + }; + + mx = mkOption { + type = listOf str; + description = "The hosts which act as the domain mail exchange."; + default = []; + }; + + gssapi-realm = mkOption { + type = nullOr str; + description = "The GSSAPI realm of this domain."; + default = null; + }; + }; + }; + + networkHostOpts = import ../types/network-host.nix { inherit lib; }; + + hostRecords = hostname: nethost-data: let + # FIXME: RP doesn't work. + # generic-host-records = let + # host-data = if (hasAttr hostname config.fudo.hosts) then config.fudo.hosts.${hostname} else null; + # in + # if (host-data == null) then [] else ( + # (map (sshfp: "${hostname} IN SSHFP ${sshfp}") host-data.ssh-fingerprints) ++ (optional (host-data.rp != null) "${hostname} IN RP ${host-data.rp}") + # ); + sshfp-records = if (hasAttr hostname config.fudo.hosts) then (map (sshfp: "${hostname} IN SSHFP ${sshfp}") config.fudo.hosts.${hostname}.ssh-fingerprints) else []; + a-record = optional (nethost-data.ipv4-address != null) "${hostname} IN A ${nethost-data.ipv4-address}"; + aaaa-record = optional (nethost-data.ipv6-address != null) "${hostname} IN AAAA ${nethost-data.ipv6-address}"; + description-record = optional (nethost-data.description != null) "${hostname} IN TXT \"${nethost-data.description}\""; + in + join-lines (a-record ++ aaaa-record ++ description-record ++ sshfp-records); + + makeSrvRecords = protocol: type: records: + join-lines (map (record: + "_${type}._${protocol} IN SRV ${toString record.priority} ${ + toString record.weight + } ${toString record.port} ${toString record.host}.") records); + + makeSrvProtocolRecords = protocol: types: + join-lines (mapAttrsToList (makeSrvRecords protocol) types); + + cnameRecord = alias: host: "${alias} IN CNAME ${host}"; + + mxRecords = mxs: concatStringsSep "\n" (map (mx: "@ IN MX 10 ${mx}.") mxs); + + dmarcRecord = dmarc-email: + optionalString (dmarc-email != null) '' + _dmarc IN TXT "v=DMARC1;p=quarantine;sp=quarantine;rua=mailto:${dmarc-email};"''; + + nsRecords = domain: ns-hosts: + join-lines + (mapAttrsToList (host: _: "@ IN NS ${host}.${domain}.") ns-hosts); + +in { + + options.fudo.dns = with types; { + enable = mkEnableOption "Enable master DNS services."; + + # FIXME: This should allow for AAAA addresses too... + nameservers = mkOption { + type = attrsOf (submodule networkHostOpts); + description = "Map of domain nameserver FQDNs to IP."; + example = { + "ns1.domain.com" = { + ipv4-address = "1.1.1.1"; + description = "my fancy dns server"; + }; + }; + }; + + identity = mkOption { + type = str; + description = "The identity (CH TXT ID.SERVER) of this host."; + }; + + domains = mkOption { + type = attrsOf (submodule domainOpts); + default = { }; + description = "A map of domain to domain options."; + }; + + listen-ips = mkOption { + type = listOf str; + description = "A list of IPs on which to listen for DNS queries."; + example = [ "1.2.3.4" ]; + }; + + state-directory = mkOption { + type = str; + description = "Path at which to store nameserver state, including DNSSEC keys."; + default = "/var/lib/nsd"; + }; + }; + + config = mkIf cfg.enable { + networking.firewall = { + allowedTCPPorts = [ 53 ]; + allowedUDPPorts = [ 53 ]; + }; + + fudo.nsd = { + enable = true; + identity = cfg.identity; + interfaces = cfg.listen-ips; + stateDir = cfg.state-directory; + zones = mapAttrs' (dom: dom-cfg: let + net-cfg = dom-cfg.network-definition; + in nameValuePair "${dom}." { + dnssec = dom-cfg.dnssec; + + data = '' + $ORIGIN ${dom}. + $TTL 12h + + @ IN SOA ns1.${dom}. hostmaster.${dom}. ( + ${toString config.instance.build-timestamp} + 30m + 2m + 3w + 5m) + + ${optionalString (dom-cfg.default-host != null) + "@ IN A ${dom-cfg.default-host}"} + + ${mxRecords dom-cfg.mx} + + $TTL 6h + + ${optionalString (dom-cfg.gssapi-realm != null) + ''_kerberos IN TXT "${dom-cfg.gssapi-realm}"''} + + ${nsRecords dom cfg.nameservers} + ${join-lines (mapAttrsToList hostRecords cfg.nameservers)} + + ${dmarcRecord dom-cfg.dmarc-report-address} + + ${join-lines + (mapAttrsToList makeSrvProtocolRecords net-cfg.srv-records)} + ${join-lines (mapAttrsToList hostRecords net-cfg.hosts)} + ${join-lines (mapAttrsToList cnameRecord net-cfg.aliases)} + ${join-lines net-cfg.verbatim-dns-records} + ''; + }) cfg.domains; + }; + }; +} diff --git a/lib/fudo/domain/dns.nix b/lib/fudo/domain/dns.nix new file mode 100644 index 0000000..bf84435 --- /dev/null +++ b/lib/fudo/domain/dns.nix @@ -0,0 +1,69 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + domain = config.instance.local-domain; + cfg = config.fudo.domains.${domain}; + + served-domain = cfg.primary-nameserver != null; + + is-primary = hostname == cfg.primary-nameserver; + + create-srv-record = port: hostname: { + port = port; + host = hostname; + }; + +in { + config = { + fudo.dns = mkIf is-primary (let + primary-ip = pkgs.lib.fudo.network.host-ipv4 config hostname; + all-ips = pkgs.lib.fudo.network.host-ips config hostname; + in { + enable = true; + identity = "${hostname}.${domain}"; + nameservers = { + ns1 = { + ipv4-address = primary-ip; + description = "Primary ${domain} nameserver"; + }; + }; + + # Deliberately leaving out localhost so the primary nameserver + # can use a custom recursor + listen-ips = all-ips; + + domains = { + ${domain} = { + dnssec = true; + default-host = primary-ip; + gssapi-realm = cfg.gssapi-realm; + mx = optional (cfg.primary-mailserver != null) + cfg.primary-mailserver; + # TODO: there's no guarantee this exists... + dmarc-report-address = "dmarc-report@${domain}"; + + network-definition = let + network = config.fudo.networks.${domain}; + in network // { + srv-records = { + tcp = { + domain = [{ + host = "ns1.${domain}"; + port = 53; + }]; + }; + udp = { + domain = [{ + host = "ns1.${domain}"; + port = 53; + }]; + }; + }; + }; + }; + }; + }); + }; +} diff --git a/lib/fudo/domain/kerberos.nix b/lib/fudo/domain/kerberos.nix new file mode 100644 index 0000000..f104c0a --- /dev/null +++ b/lib/fudo/domain/kerberos.nix @@ -0,0 +1,74 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + domain = config.instance.local-domain; + cfg = config.fudo.domains.${domain}; + +in { + config = let + hostname = config.instance.hostname; + is-master = hostname == cfg.kerberos-master; + is-slave = elem hostname cfg.kerberos-slaves; + + kerberized-domain = cfg.kerberos-master != null; + + in { + fudo = { + auth.kdc = mkIf (is-master || is-slave) { + enable = true; + realm = cfg.gssapi-realm; + # TODO: Also bind to ::1? + bind-addresses = + (pkgs.lib.fudo.network.host-ips config hostname) ++ + [ "127.0.0.1" ] ++ (optional config.networking.enableIPv6 "::1"); + master-config = mkIf is-master { + acl = let + admin-entries = genAttrs cfg.local-admins + (admin: { + perms = [ "add" "change-password" "list" ]; + }); + in admin-entries // { + "*/root" = { perms = [ "all" ]; }; + }; + }; + slave-config = mkIf is-slave { + master-host = cfg.kerberos-master; + # You gotta provide the keytab yourself, sorry... + }; + }; + + dns.domains.${domain} = { + network-definition = mkIf kerberized-domain { + srv-records = let + get-fqdn = hostname: + "${hostname}.${config.fudo.hosts.${hostname}.domain}"; + + create-srv-record = port: hostname: { + port = port; + host = hostname; + }; + + all-servers = map get-fqdn + ([cfg.kerberos-master] ++ cfg.kerberos-slaves); + + master-servers = + map get-fqdn [cfg.kerberos-master]; + + in { + tcp = { + kerberos = map (create-srv-record 88) all-servers; + kerberos-adm = map (create-srv-record 749) master-servers; + }; + udp = { + kerberos = map (create-srv-record 88) all-servers; + kerberos-master = map (create-srv-record 88) master-servers; + kpasswd = map (create-srv-record 464) master-servers; + }; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/domains.nix b/lib/fudo/domains.nix new file mode 100644 index 0000000..5b6202b --- /dev/null +++ b/lib/fudo/domains.nix @@ -0,0 +1,94 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + domain = config.instance.local-domain; + + domainOpts = { name, ... }: let + domain = name; + in { + options = with types; { + domain = mkOption { + type = str; + description = "Domain name."; + default = domain; + }; + + local-networks = mkOption { + type = listOf str; + description = + "A list of networks to be considered trusted on this network."; + default = [ ]; + }; + + local-users = mkOption { + type = listOf str; + description = + "A list of users who should have local (i.e. login) access to _all_ hosts in this domain."; + default = [ ]; + }; + + local-admins = mkOption { + type = listOf str; + description = + "A list of users who should have admin access to _all_ hosts in this domain."; + default = [ ]; + }; + + local-groups = mkOption { + type = listOf str; + description = "List of groups which should exist within this domain."; + default = [ ]; + }; + + admin-email = mkOption { + type = str; + description = "Email for the administrator of this domain."; + default = "admin@${domain}"; + }; + + gssapi-realm = mkOption { + type = str; + description = "GSSAPI (i.e. Kerberos) realm of this domain."; + default = toUpper domain; + }; + + kerberos-master = mkOption { + type = nullOr str; + description = "Hostname of the Kerberos master server for the domain, if applicable."; + default = null; + }; + + kerberos-slaves = mkOption { + type = listOf str; + description = "List of hosts acting as Kerberos slaves for the domain."; + default = []; + }; + + primary-nameserver = mkOption { + type = nullOr str; + description = "Hostname of the primary nameserver for this domain."; + default = null; + }; + + primary-mailserver = mkOption { + type = nullOr str; + description = "Hostname of the primary mail server for this domain."; + default = null; + }; + }; + }; + +in { + options.fudo.domains = mkOption { + type = with types; attrsOf (submodule domainOpts); + description = "Domain configurations for all domains known to the system."; + default = { }; + }; + + imports = [ + ./domain/kerberos.nix + ./domain/dns.nix + ]; +} diff --git a/lib/fudo/garbage-collector.nix b/lib/fudo/garbage-collector.nix new file mode 100644 index 0000000..dfa2488 --- /dev/null +++ b/lib/fudo/garbage-collector.nix @@ -0,0 +1,35 @@ +{ config, lib, pkgs, ... }: + +with lib; +let cfg = config.fudo.garbage-collector; + +in { + + options.fudo.garbage-collector = { + enable = mkEnableOption "Enable periodic NixOS garbage collection"; + + timing = mkOption { + type = types.str; + default = "weekly"; + description = + "Period (systemd format) at which to run garbage collector."; + }; + + age = mkOption { + type = types.str; + default = "30d"; + description = "Age of garbage to collect (eg. 30d)."; + }; + }; + + config = mkIf cfg.enable { + fudo.system.services.fudo-garbage-collector = { + description = "Collect NixOS garbage older than ${cfg.age}."; + onCalendar = cfg.timing; + type = "oneshot"; + script = + "${pkgs.nix}/bin/nix-collect-garbage --delete-older-than ${cfg.age}"; + addressFamilies = [ ]; + }; + }; +} diff --git a/lib/fudo/git.nix b/lib/fudo/git.nix new file mode 100644 index 0000000..4988645 --- /dev/null +++ b/lib/fudo/git.nix @@ -0,0 +1,171 @@ +{ pkgs, lib, config, ... }: + +with lib; +let + cfg = config.fudo.git; + + databaseOpts = { ... }: { + options = { + name = mkOption { + type = types.str; + description = "Database name."; + }; + hostname = mkOption { + type = types.str; + description = "Hostname of the database server."; + }; + user = mkOption { + type = types.str; + description = "Database username."; + }; + password-file = mkOption { + type = types.path; + description = "File containing the database user's password."; + }; + }; + }; + + sshOpts = { ... }: + with types; { + options = { + listen-ip = mkOption { + type = str; + description = "IP on which to listen for SSH connections."; + }; + + listen-port = mkOption { + type = port; + description = + "Port on which to listen for SSH connections, on ."; + default = 22; + }; + }; + }; + +in { + options.fudo.git = with types; { + enable = mkEnableOption "Enable Fudo git web server."; + + hostname = mkOption { + type = str; + description = "Hostname at which this git server is accessible."; + example = "git.fudo.org"; + }; + + site-name = mkOption { + type = str; + description = "Name to use for the git server."; + default = "Fudo Git"; + }; + + database = mkOption { + type = (submodule databaseOpts); + description = "Gitea database options."; + }; + + repository-dir = mkOption { + type = str; + description = "Path at which to store repositories."; + example = "/srv/git/repo"; + }; + + state-dir = mkOption { + type = str; + description = "Path at which to store server state."; + example = "/srv/git/state"; + }; + + user = mkOption { + type = with types; nullOr str; + description = "System user as which to run."; + default = "git"; + }; + + local-port = mkOption { + type = port; + description = + "Local port to which the Gitea server will bind. Not globally accessible."; + default = 3543; + }; + + ssh = mkOption { + type = nullOr (submodule sshOpts); + description = "SSH listen configuration."; + default = null; + }; + }; + + config = mkIf cfg.enable { + security.acme.certs.${cfg.hostname}.email = + let domain-name = config.fudo.hosts.${config.instance.hostname}.domain; + in config.fudo.domains.${domain-name}.admin-email; + + networking.firewall.allowedTCPPorts = + mkIf (cfg.ssh != null) [ cfg.ssh.listen-port ]; + + environment.systemPackages = with pkgs; let + gitea-admin = writeShellScriptBin "gitea-admin" '' + TMP=$(mktemp -d /tmp/gitea-XXXXXXXX) + ${gitea}/bin/gitea --custom-path ${cfg.state-dir}/custom --config ${cfg.state-dir}/custom/conf/app.ini --work-path $TMP $@ + ''; + in [ + gitea-admin + ]; + + services = { + gitea = { + enable = true; + appName = cfg.site-name; + database = { + createDatabase = false; + host = cfg.database.hostname; + name = cfg.database.name; + user = cfg.database.user; + passwordFile = cfg.database.password-file; + type = "postgres"; + }; + domain = cfg.hostname; + httpAddress = "127.0.0.1"; + httpPort = cfg.local-port; + repositoryRoot = cfg.repository-dir; + stateDir = cfg.state-dir; + rootUrl = "https://${cfg.hostname}/"; + user = mkIf (cfg.user != null) cfg.user; + ssh = { + enable = true; + clonePort = cfg.ssh.listen-port; + }; + settings = mkIf (cfg.ssh != null) { + server = { + SSH_DOMAIN = cfg.hostname; + SSH_LISTEN_PORT = cfg.ssh.listen-port; + SSH_LISTEN_HOST = cfg.ssh.listen-ip; + }; + }; + }; + + nginx = { + enable = true; + + virtualHosts = { + "${cfg.hostname}" = { + enableACME = true; + forceSSL = true; + + locations."/" = { + proxyPass = "http://127.0.0.1:${toString cfg.local-port}"; + + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/global.nix b/lib/fudo/global.nix new file mode 100644 index 0000000..f8e497b --- /dev/null +++ b/lib/fudo/global.nix @@ -0,0 +1,5 @@ +{ config, lib, pkgs, ... }: + +with lib; { + config = { }; +} diff --git a/lib/fudo/grafana.nix b/lib/fudo/grafana.nix new file mode 100644 index 0000000..6bed0e5 --- /dev/null +++ b/lib/fudo/grafana.nix @@ -0,0 +1,143 @@ +# NOTE: this assumes that postgres is running locally. + +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.grafana; + fudo-cfg = config.fudo.common; + + database-name = "grafana"; + database-user = "grafana"; + + databaseOpts = { ... }: { + options = { + name = mkOption { + type = types.str; + description = "Database name."; + }; + hostname = mkOption { + type = types.str; + description = "Hostname of the database server."; + }; + user = mkOption { + type = types.str; + description = "Database username."; + }; + password-file = mkOption { + type = types.path; + description = "File containing the database user's password."; + }; + }; + }; + +in { + + options.fudo.grafana = { + enable = mkEnableOption "Fudo Metrics Display Service"; + + hostname = mkOption { + type = types.str; + description = "Grafana site hostname."; + example = "fancy-graphs.fudo.org"; + }; + + smtp-username = mkOption { + type = types.str; + description = "Username with which to send email."; + }; + + smtp-password-file = mkOption { + type = types.path; + description = "Path to a file containing the email user's password."; + }; + + database = mkOption { + type = (types.submodule databaseOpts); + description = "Grafana database configuration."; + }; + + admin-password-file = mkOption { + type = types.path; + description = "Path to a file containing the admin user's password."; + }; + + secret-key-file = mkOption { + type = types.path; + description = "Path to a file containing the server's secret key, used for signatures."; + }; + + prometheus-host = mkOption { + type = types.str; + description = "The URL of the prometheus data source."; + }; + }; + + config = mkIf cfg.enable { + security.acme.certs.${cfg.hostname}.email = fudo-cfg.admin-email; + + services.nginx = { + enable = true; + + virtualHosts = { + "${cfg.hostname}" = { + enableACME = true; + forceSSL = true; + + locations."/" = { + proxyPass = "http://127.0.0.1:3000"; + + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; + }; + }; + + services.grafana = { + enable = true; + + addr = "127.0.0.1"; + protocol = "http"; + port = 3000; + domain = "${cfg.hostname}"; + rootUrl = "https://${cfg.hostname}/"; + + security = { + adminPasswordFile = cfg.admin-password-file; + secretKeyFile = cfg.secret-key-file; + }; + + smtp = { + enable = true; + fromAddress = "metrics@fudo.org"; + host = "mail.fudo.org:25"; + user = cfg.smtp-username; + passwordFile = cfg.smtp-password-file; + }; + + database = { + host = cfg.database.hostname; + name = cfg.database.name; + user = cfg.database.user; + passwordFile = cfg.database.password-file; + type = "postgres"; + }; + + provision.datasources = [ + { + editable = false; + isDefault = true; + name = cfg.prometheus-host; + type = "prometheus"; + url = "https://${cfg.prometheus-host}/"; + } + ]; + }; + }; +} diff --git a/lib/fudo/host-filesystems.nix b/lib/fudo/host-filesystems.nix new file mode 100644 index 0000000..e3c3214 --- /dev/null +++ b/lib/fudo/host-filesystems.nix @@ -0,0 +1,123 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + host-filesystems = config.fudo.hosts.${hostname}.encrypted-filesystems; + + optionalOrDefault = str: default: if (str != null) then str else default; + + filesystemsToMountpointLists = mapAttrsToList + (fs: fsOpts: fsOpts.mountpoints); + + concatMapAttrs = f: as: concatMap (i: i) (mapAttrsToList f as); + + concatMapAttrsToList = f: attrs: + concatMap (i: i) (mapAttrsToList f attrs); + +in { + config = { + users.groups = let + site-name = config.instance.local-site; + site-hosts = filterAttrs + (hostname: hostOpts: hostOpts.site == site-name) + config.fudo.hosts; + site-mountpoints = concatMapAttrsToList + (host: hostOpts: concatMapAttrsToList + (fs: fsOpts: attrValues fsOpts.mountpoints) + hostOpts.encrypted-filesystems) + site-hosts; + in listToAttrs + (map (mp: nameValuePair mp.group { members = mp.users; }) + site-mountpoints); + + systemd = { + # Ensure the mountpoints exist + tmpfiles.rules = let + mpPerms = mpOpts: if mpOpts.world-readable then "755" else "750"; + mountpointToPath = mp: mpOpts: + "d '${mp}' ${mpPerms mpOpts} root ${optionalOrDefault mpOpts.group "-"} - -"; + filesystemsToMountpointLists = mapAttrsToList + (fs: fsOpts: fsOpts.mountpoints); + mountpointListsToPaths = concatMap + (mps: mapAttrsToList mountpointToPath mps); + in mountpointListsToPaths (filesystemsToMountpointLists host-filesystems); + + # Actual mounts of decrypted filesystems + mounts = let + filesystems = mapAttrsToList + (fs: opts: { filesystem = fs; opts = opts; }) + host-filesystems; + + mounts = concatMap + (fs: mapAttrsToList + (mp: mp-opts: + { + what = "/dev/mapper/${fs.filesystem}"; + type = fs.opts.filesystem-type; + where = mp; + options = concatStringsSep "," (fs.opts.options ++ mp-opts.options); + description = "${fs.opts.filesystem-type} filesystem on ${fs.filesystem} mounted to ${mp}"; + requires = [ "${fs.filesystem}-decrypt.service" ]; + partOf = [ "${fs.filesystem}.target" ]; + wantedBy = [ "${fs.filesystem}.target" ]; + }) + fs.opts.mountpoints) + filesystems; + in mounts; + + # Jobs to decrypt the encrypted devices + services = mapAttrs' (filesystem-name: opts: + nameValuePair "${filesystem-name}-decrypt" + { + description = "Decrypt the ${filesystem-name} filesystem when the key is available at ${opts.key-path}"; + path = with pkgs; [ cryptsetup ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "decrypt-${filesystem-name}.sh" '' + [ -e /dev/mapper/${filesystem-name} ] || cryptsetup open --type luks --key-file ${opts.key-path} ${opts.encrypted-device} ${filesystem-name} + ''; + ExecStartPost = pkgs.writeShellScript "remove-${filesystem-name}-key.sh" '' + rm ${opts.key-path} + ''; + ExecStop = pkgs.writeShellScript "close-${filesystem-name}.sh" '' + cryptsetup close /dev/mapper/${filesystem-name} + ''; + }; + restartIfChanged = true; + }) + host-filesystems; + + # Watch the path of the key, trigger decrypt when it's available + paths = let + decryption-jobs = mapAttrs' (filesystem-name: opts: + nameValuePair "${filesystem-name}-decrypt" + { + wantedBy = [ "default.target" ]; + description = "Watch for decryption key, then decrypt the target filesystem."; + pathConfig = { + PathExists = opts.key-path; + Unit = "${filesystem-name}-decrypt.service"; + }; + }) host-filesystems; + + post-decryption-jobs = mapAttrs' (filesystem-name: opts: + nameValuePair "${filesystem-name}-mount" + { + wantedBy = [ "default.target" ]; + description = "Mount ${filesystem-name} filesystems once the decrypted device is available."; + pathConfig = { + PathExists = "/dev/mapper/${filesystem-name}"; + Unit = "${filesystem-name}.target"; + }; + }) host-filesystems; + in decryption-jobs // post-decryption-jobs; + + targets = mapAttrs (filesystem-name: opts: + { + description = "${filesystem-name} enabled and available."; + }) host-filesystems; + }; + }; +} diff --git a/lib/fudo/hosts.nix b/lib/fudo/hosts.nix new file mode 100644 index 0000000..3c169f8 --- /dev/null +++ b/lib/fudo/hosts.nix @@ -0,0 +1,127 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + mapOptional = f: val: if (val != null) then (f val) else null; + + host = import ../types/host.nix { inherit lib; }; + + hostname = config.instance.hostname; + + generate-string-hash = name: str: let + string-hash-pkg = pkgs.stdenv.mkDerivation { + name = "${name}-string-hash"; + phases = "installPhase"; + buildInputs = [ pkgs.openssl ]; + installPhase = "openssl passwd -6 ${str} > $out"; + }; + in string-hash-pkg; + +in { + options.fudo.hosts = with types; + mkOption { + type = attrsOf (submodule host.hostOpts); + description = "Host configurations for all hosts known to the system."; + default = { }; + }; + + config = let + hostname = config.instance.hostname; + host-cfg = config.fudo.hosts.${hostname}; + site-name = host-cfg.site; + site = config.fudo.sites.${site-name}; + domain-name = host-cfg.domain; + domain = config.fudo.domains.${domain-name}; + has-build-servers = (length (attrNames site.build-servers)) > 0; + has-build-keys = (length host-cfg.build-pubkeys) > 0; + + in { + security.sudo.extraConfig = '' + # I get it, I get it + Defaults lecture = never + ''; + + networking = { + hostName = config.instance.hostname; + domain = domain-name; + nameservers = site.nameservers; + # This will cause a loop on the gateway itself + #defaultGateway = site.gateway-v4; + #defaultGateway6 = site.gateway-v6; + + firewall = mkIf ((length host-cfg.external-interfaces) > 0) { + enable = true; + allowedTCPPorts = [ 22 2112 ]; # Make sure _at least_ SSH is allowed + trustedInterfaces = let + all-interfaces = attrNames config.networking.interfaces; + in subtractLists host-cfg.external-interfaces all-interfaces; + }; + + hostId = mkIf (host-cfg.machine-id != null) + (substring 0 8 host-cfg.machine-id); + }; + + environment = { + etc = { + # NixOS generates a stupid hosts file, just force it + hosts = let + host-entries = mapAttrsToList + (ip: hostnames: "${ip} ${concatStringsSep " " hostnames}") + config.fudo.system.hostfile-entries; + in mkForce { + text = '' + 127.0.0.1 ${hostname}.${domain-name} ${hostname} localhost + 127.0.0.2 ${hostname} localhost + ::1 ${hostname}.${domain-name} ${hostname} localhost + ${concatStringsSep "\n" host-entries} + ''; + user = "root"; + group = "root"; + mode = "0444"; + }; + + machine-id = mkIf (host-cfg.machine-id != null) { + text = host-cfg.machine-id; + user = "root"; + group = "root"; + mode = "0444"; + }; + + current-system-packages.text = with builtins; let + packages = map (p: "${p.name}") + config.environment.systemPackages; + sorted-unique = sort lessThan (unique packages); + in concatStringsSep "\n" sorted-unique; + + build-timestamp.text = toString config.instance.build-timestamp; + build-seed-hash.source = + generate-string-hash "build-seed" config.instance.build-seed; + }; + + systemPackages = with pkgs; + mkIf (host-cfg.docker-server) [ docker nix-prefetch-docker ]; + }; + + time.timeZone = site.timezone; + + krb5.libdefaults.default_realm = domain.gssapi-realm; + + services = { + cron.mailto = domain.admin-email; + fail2ban.ignoreIP = config.instance.local-networks; + }; + + virtualisation.docker = mkIf (host-cfg.docker-server) { + enable = true; + enableOnBoot = true; + autoPrune.enable = true; + }; + + programs.adb.enable = host-cfg.android-dev; + users.groups.adbusers = mkIf host-cfg.android-dev { + members = config.instance.local-admins; + }; + + boot.tmpOnTmpfs = host-cfg.tmp-on-tmpfs; + }; +} diff --git a/lib/fudo/hosts/local-network.nix b/lib/fudo/hosts/local-network.nix new file mode 100644 index 0000000..f2de116 --- /dev/null +++ b/lib/fudo/hosts/local-network.nix @@ -0,0 +1,143 @@ +# THROW THIS AWAY, NOT USED + +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.hosts.local-network; + + # FIXME: this isn't used, is it? + gatewayServerOpts = { ... }: { + options = { + enable = mkEnableOption "Turn this host into a network gateway."; + + internal-interfaces = mkOption { + type = with types; listOf str; + description = + "List of internal interfaces from which to forward traffic."; + default = [ ]; + }; + + external-interface = mkOption { + type = types.str; + description = + "Interface facing public internet, to which traffic is forwarded."; + }; + + external-tcp-ports = mkOption { + type = with types; listOf port; + description = "List of TCP ports to open to the outside world."; + default = [ ]; + }; + + external-udp-ports = mkOption { + type = with types; listOf port; + description = "List of UDP ports to open to the outside world."; + default = [ ]; + }; + }; + }; + + dnsOverHttpsProxy = { + options = { + enable = mkEnableOption "Enable a DNS-over-HTTPS proxy server."; + + listen-port = mkOption { + type = types.port; + description = "Port on which to listen for DNS requests."; + default = 53; + }; + + upstream-dns = mkOption { + type = with types; listOf str; + description = "List of DoH DNS servers to use for recursion."; + default = [ ]; + }; + + bootstrap-dns = mkOption { + type = types.str; + description = "DNS server used to bootstrap the proxy server."; + default = "1.1.1.1"; + }; + }; + }; + + networkDhcpServerOpts = mkOption { + options = { + enable = mkEnableOption "Enable local DHCP server."; + + dns-servers = mkOption { + type = with types; listOf str; + description = "List of DNS servers for clients to use."; + default = [ ]; + }; + + listen-interfaces = mkOption { + type = with types; listOf str; + description = "List of interfaces on which to serve DHCP requests."; + default = [ ]; + }; + + server-ip = mkOption { + type = types.str; + description = "IP address of the server host."; + }; + }; + }; + + networkServerOpts = { + options = { + enable = mkEnableOption "Enable local networking server (DNS & DHCP)."; + + domain = mkOption { + type = types.str; + description = "Local network domain which this host will serve."; + }; + + dns-listen-addrs = mkOption { + type = with types; listOf str; + description = "List of IP addresses on which to listen for requests."; + default = [ ]; + }; + + dhcp = mkOption { + type = types.submodule networkDhcpServerOpts; + description = "Local DHCP server options."; + }; + }; + }; + +in { + options.fudo.hosts.local-network = with types; { + recursive-resolvers = mkOption { + type = listOf str; + description = "DNS server to use for recursive lookups."; + example = "1.2.3.4 port 53"; + }; + + gateway-server = mkOption { + type = submodule gatewayServerOpts; + description = "Gateway server options."; + }; + + dns-over-https-proxy = mkOption { + type = submodule dnsOverHttpsProxy; + description = "DNS-over-HTTPS proxy server."; + }; + + networkServerOpts = mkOption { + type = submodule networkServerOpts; + description = "Networking (DNS & DHCP) server for a local network."; + }; + }; + + config = { + fudo.secure-dns-proxy = mkIf cfg.dns-over-https-proxy.enable { + enable = true; + port = cfg.dns-over-https-proxy.listen-port; + upstream-dns = cfg.dns-over-https-proxy.upstream-dns; + bootstrap-dns = cfg.dns-over-https-proxy.bootstrap-dns; + listen-ips = cfg.dns-over-https-proxy.listen-ips; + }; + }; +} diff --git a/lib/fudo/include/rainloop.nix b/lib/fudo/include/rainloop.nix new file mode 100644 index 0000000..3172e6e --- /dev/null +++ b/lib/fudo/include/rainloop.nix @@ -0,0 +1,117 @@ +lib: site: config: version: +with lib; +let + db-config = optionalString (config.database != null) + '' + type = "${config.database.type}" + pdo_dsn = "${config.database.type}:host=${config.database.hostname};port=${toString config.database.port};dbname=${config.database.name}" + pdo_user = "${config.database.user}" + pdo_password = "${fileContents config.database.password-file}" + ''; + +in '' + [webmail] + title = "${config.title}" + loading_description = "${config.title}" + favicon_url = "https://${site}/favicon.ico" + theme = "${config.theme}" + allow_themes = On + allow_user_background = Off + language = "en" + language_admin = "en" + allow_languages_on_settings = On + allow_additional_accounts = On + allow_additional_identities = On + messages_per_page = ${toString config.messages-per-page} + attachment_size_limit = ${toString config.max-upload-size} + + [interface] + show_attachment_thumbnail = On + new_move_to_folder_button = On + + [branding] + + [contacts] + enable = On + allow_sync = On + sync_interval = 20 + suggestions_limit = 10 + ${db-config} + + [security] + csrf_protection = On + custom_server_signature = "RainLoop" + x_frame_options_header = "" + openpgp = On + + admin_login = "admin" + admin_password = "" + allow_admin_panel = Off + allow_two_factor_auth = On + force_two_factor_auth = Off + hide_x_mailer_header = Off + admin_panel_host = "" + admin_panel_key = "admin" + content_security_policy = "" + core_install_access_domain = "" + + [login] + default_domain = "${config.domain}" + allow_languages_on_login = On + determine_user_language = On + determine_user_domain = Off + welcome_page = Off + hide_submit_button = On + + [plugins] + enable = Off + + [defaults] + view_editor_type = "${config.edit-mode}" + view_layout = ${if (config.layout-mode == "bottom") then "2" else "1"} + contacts_autosave = On + mail_use_threads = ${if config.enable-threading then "On" else "Off"} + allow_draft_autosave = On + mail_reply_same_folder = Off + show_images = On + + [logs] + enable = ${if config.debug then "On" else "Off"} + + [debug] + enable = ${if config.debug then "On" else "Off"} + hide_passwords = On + filename = "log-{date:Y-m-d}.txt" + + [social] + google_enable = Off + fb_enable = Off + twitter_enable = Off + dropbox_enable = Off + + [cache] + enable = On + index = "v1" + fast_cache_driver = "files" + fast_cache_index = "v1" + http = On + http_expires = 3600 + server_uids = On + + [labs] + allow_mobile_version = ${if config.enable-mobile then "On" else "Off"} + check_new_password_strength = On + allow_gravatar = On + allow_prefetch = On + allow_smart_html_links = On + cache_system_data = On + date_from_headers = On + autocreate_system_folders = On + allow_ctrl_enter_on_compose = On + favicon_status = On + use_local_proxy_for_external_images = On + detect_image_exif_orientation = On + + [version] + current = "${version}" +'' diff --git a/lib/fudo/initrd-network.nix b/lib/fudo/initrd-network.nix new file mode 100644 index 0000000..a3c8c35 --- /dev/null +++ b/lib/fudo/initrd-network.nix @@ -0,0 +1,87 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + initrd-cfg = config.fudo.hosts.${hostname}.initrd-network; + + read-lines = filename: splitString "\n" (fileContents filename); + + concatLists = lsts: concatMap (i: i) lsts; + + gen-sshfp-records-pkg = hostname: pubkey: let + pubkey-file = builtins.toFile "${hostname}-initrd-ssh-pubkey" pubkey; + in pkgs.stdenv.mkDerivation { + name = "${hostname}-initrd-ssh-firngerprint"; + + phases = [ "installPhase" ]; + + buildInputs = with pkgs; [ openssh ]; + + installPhase = '' + mkdir $out + ssh-keygen -r REMOVEME -f "${pubkey-file}" | sed 's/^REMOVEME IN SSHFP //' >> $out/initrd-ssh-pubkey.sshfp + ''; + }; + + gen-sshfp-records = hostname: pubkey: let + sshfp-record-pkg = gen-sshfp-records-pkg hostname pubkey; + in read-lines "${sshfp-record-pkg}/initrd-ssh-pubkey.sshfp"; + +in { + config = { + boot = mkIf (initrd-cfg != null) { + kernelParams = let + site = config.fudo.sites.${config.instance.local-site}; + site-gateway = site.gateway-v4; + netmask = + pkgs.lib.fudo.ip.maskFromV32Network site.network; + in [ + "ip=${initrd-cfg.ip}:${site-gateway}:${netmask}:${hostname}:${initrd-cfg.interface}" + ]; + initrd = { + network = { + enable = true; + + ssh = let + admin-ssh-keys = + concatMap (admin: config.fudo.users.${admin}.ssh-authorized-keys) + config.instance.local-admins; + in { + enable = true; + port = 22; + authorizedKeys = admin-ssh-keys; + hostKeys = [ + initrd-cfg.keypair.private-key-file + ]; + }; + }; + }; + }; + + fudo = { + local-network = let + initrd-network-hosts = + filterAttrs + (hostname: hostOpts: hostOpts.initrd-network != null) + config.instance.local-hosts; + in { + network-definition.hosts = mapAttrs' + (hostname: hostOpts: nameValuePair "${hostname}-recovery" + { + ipv4-address = hostOpts.initrd-network.ip; + description = "${hostname} initrd host"; + }) + initrd-network-hosts; + + extra-records = let + recs = (mapAttrsToList + (hostname: hostOpts: map + (sshfp: "${hostname} IN SSHFP ${sshfp}") + (gen-sshfp-records hostname hostOpts.initrd-network.keypair.public-key)) + initrd-network-hosts); + in concatLists recs; + }; + }; + }; +} diff --git a/lib/fudo/ipfs.nix b/lib/fudo/ipfs.nix new file mode 100644 index 0000000..b60ac0c --- /dev/null +++ b/lib/fudo/ipfs.nix @@ -0,0 +1,66 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.ipfs; + + user-group-entry = group: user: + nameValuePair user { extraGroups = [ group ]; }; + +in { + options.fudo.ipfs = with types; { + enable = mkEnableOption "Fudo IPFS"; + + users = mkOption { + type = listOf str; + description = "List of users with IPFS access."; + default = [ ]; + }; + + user = mkOption { + type = str; + description = "User as which to run IPFS user."; + default = "ipfs"; + }; + + group = mkOption { + type = str; + description = "Group as which to run IPFS user."; + default = "ipfs"; + }; + + api-address = mkOption { + type = str; + description = "Address on which to listen for requests."; + default = "/ip4/127.0.0.1/tcp/5001"; + }; + + automount = mkOption { + type = bool; + description = "Whether to automount /ipfs and /ipns on boot."; + default = true; + }; + + data-dir = mkOption { + type = str; + description = "Path to store data for IPFS."; + default = "/var/lib/ipfs"; + }; + }; + + config = mkIf cfg.enable { + + users.users = + mapAttrs user-group-entry config.instance.local-users; + + services.ipfs = { + enable = true; + apiAddress = cfg.api-address; + autoMount = cfg.automount; + enableGC = true; + user = cfg.user; + group = cfg.group; + dataDir = cfg.data-dir; + }; + }; +} diff --git a/lib/fudo/jabber.nix b/lib/fudo/jabber.nix new file mode 100644 index 0000000..a048063 --- /dev/null +++ b/lib/fudo/jabber.nix @@ -0,0 +1,236 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + + siteOpts = { ... }: with types; { + options = { + enableACME = mkOption { + type = bool; + description = "Use ACME to get SSL certificates for this site."; + default = true; + }; + + site-config = mkOption { + type = attrs; + description = "Site-specific configuration."; + }; + }; + }; + + concatMapAttrs = f: attrs: + foldr (a: b: a // b) {} (mapAttrs f attrs); + + concatMapAttrsToList = f: attr: + concatMap (i: i) (attrValues (mapAttrs f attr)); + + host-domains = config.fudo.acme.host-domains.${hostname}; + + siteCerts = site: let + cert-copy = host-domains.${site}.local-copies.ejabberd; + in [ + cert-copy.certificate + cert-copy.private-key + cert-copy.chain + ]; + + siteCertService = site: + host-domains.${site}.local-copies.ejabberd.service; + + config-file-template = let + jabber-config = { + loglevel = cfg.log-level; + + access_rules = { + c2s = { allow = "all"; }; + announce = { allow = "admin"; }; + configure = { allow = "admin"; }; + pubsub_createnode = { allow = "local"; }; + }; + + acl = { + admin = { + user = concatMap + (admin: map (site: "${admin}@${site}") + (attrNames cfg.sites)) + cfg.admins; + }; + }; + + hosts = attrNames cfg.sites; + + listen = map (ip: { + port = cfg.port; + module = "ejabberd_c2s"; + ip = ip; + starttls = true; + starttls_required = true; + }) cfg.listen-ips; + + certfiles = concatMapAttrsToList + (site: siteOpts: + if (siteOpts.enableACME) then + (siteCerts site) + else []) + cfg.sites; + + host_config = + mapAttrs (site: siteOpts: siteOpts.site-config) + cfg.sites; + }; + + config-file = builtins.toJSON jabber-config; + in pkgs.writeText "ejabberd.config.yml.template" config-file; + + enter-secrets = template: secrets: target: let + secret-readers = concatStringsSep "\n" + (mapAttrsToList + (secret: file: "${secret}=$(cat ${file})") + secrets); + secret-swappers = map + (secret: "sed s/${secret}/\$${secret}/g") + (attrNames secrets); + swapper = concatStringsSep " | " secret-swappers; + in pkgs.writeShellScript "ejabberd-generate-config.sh" '' + cat ${template} | ${swapper} > ${target} + ''; + + cfg = config.fudo.jabber; + +in { + options.fudo.jabber = with types; { + enable = mkEnableOption "Enable ejabberd server."; + + listen-ips = mkOption { + type = listOf str; + description = "IPs on which to listen for Jabber connections."; + }; + + port = mkOption { + type = port; + description = "Port on which to listen for Jabber connections."; + default = 5222; + }; + + user = mkOption { + type = str; + description = "User as which to run the ejabberd server."; + default = "ejabberd"; + }; + + group = mkOption { + type = str; + description = "Group as which to run the ejabberd server."; + default = "ejabberd"; + }; + + admins = mkOption { + type = listOf str; + description = "List of admin users for the server."; + default = []; + }; + + sites = mkOption { + type = attrsOf (submodule siteOpts); + description = "List of sites on which to listen for Jabber connections."; + }; + + secret-files = mkOption { + type = attrsOf str; + description = "Map of secret-name to file. File contents will be subbed for the name in the config."; + default = {}; + }; + + config-file = mkOption { + type = str; + description = "Location at which to generate the configuration file."; + default = "/run/ejabberd/ejabberd.yaml"; + }; + + log-level = mkOption { + type = int; + description = '' + Log level at which to run the server. + + See: https://docs.ejabberd.im/admin/guide/troubleshooting/ + ''; + default = 3; + }; + + environment = mkOption { + type = attrsOf str; + description = "Environment variables to set for the ejabberd daemon."; + default = {}; + }; + }; + + config = mkIf cfg.enable { + users = { + users.${cfg.user} = { + isSystemUser = true; + }; + + groups.${cfg.group} = { + members = [ cfg.user ]; + }; + }; + + fudo = { + acme.host-domains.${hostname} = mapAttrs (site: siteCfg: + mkIf siteCfg.enableACME { + local-copies.ejabberd = { + user = cfg.user; + group = cfg.group; + }; + }) cfg.sites; + + system = let + config-dir = dirOf cfg.config-file; + in { + ensure-directories.${config-dir} = { + user = cfg.user; + perms = "0700"; + }; + + services.ejabberd-config-generator = let + config-generator = + enter-secrets config-file-template cfg.secret-files cfg.config-file; + in { + script = "${config-generator}"; + readWritePaths = [ config-dir ]; + workingDirectory = config-dir; + user = cfg.user; + description = "Generate ejabberd config file with necessary passwords."; + postStart = '' + chown ${cfg.user} ${cfg.config-file} + chmod 0400 ${cfg.config-file} + ''; + }; + }; + }; + + systemd = { + tmpfiles.rules = [ + "D '${dirOf cfg.config-file}' 0550 ${cfg.user} ${cfg.group} - -" + ]; + + services = { + ejabberd = { + wants = map (site: siteCertService site) (attrNames cfg.sites); + requires = [ "ejabberd-config-generator.service" ]; + environment = cfg.environment; + }; + }; + }; + + services.ejabberd = { + enable = true; + + user = cfg.user; + group = cfg.group; + + configFile = cfg.config-file; + }; + }; +} diff --git a/lib/fudo/kdc.nix b/lib/fudo/kdc.nix new file mode 100644 index 0000000..c093080 --- /dev/null +++ b/lib/fudo/kdc.nix @@ -0,0 +1,532 @@ +{ config, lib, pkgs, ... } @ toplevel: + +with lib; +let + cfg = config.fudo.auth.kdc; + + hostname = config.instance.hostname; + + localhost-ips = let + addr-only = addrinfo: addrinfo.address; + interface = config.networking.interfaces.lo; + in + (map addr-only interface.ipv4.addresses) ++ + (map addr-only interface.ipv6.addresses); + + host-ips = + (pkgs.lib.fudo.network.host-ips hostname) ++ localhost-ips; + + state-directory = toplevel.config.fudo.auth.kdc.state-directory; + + database-file = "${state-directory}/principals.db"; + iprop-log = "${state-directory}/iprop.log"; + + master-server = cfg.master-config != null; + slave-server = cfg.slave-config != null; + + get-fqdn = hostname: + "${hostname}.${config.fudo.hosts.${hostname}.domain}"; + + kdc-conf = generate-kdc-conf { + realm = cfg.realm; + db-file = database-file; + key-file = cfg.master-key-file; + acl-data = if master-server then cfg.master-config.acl else null; + }; + + initialize-db = + { realm, user, group, kdc-conf, key-file, db-name, max-lifetime, max-renewal, + primary-keytab, kadmin-keytab, kpasswd-keytab, ipropd-keytab, local-hostname }: let + + kadmin-cmd = "kadmin -l -c ${kdc-conf} --"; + + get-domain-hosts = domain: let + host-in-subdomain = host: hostOpts: + (builtins.match "(.+[.])?${domain}$" hostOpts.domain) != null; + in attrNames (filterAttrs host-in-subdomain config.fudo.hosts); + + get-host-principals = realm: hostname: let + host = config.fudo.hosts.${hostname}; + in map (service: "${service}/${hostname}.${host.domain}@${realm}") + host.kerberos-services; + + add-principal-str = principal: + "${kadmin-cmd} add --random-key --use-defaults ${principal}"; + + test-existence = principal: + "[[ $( ${kadmin-cmd} get ${principal} ) ]]"; + + exists-or-add = principal: '' + if ${test-existence principal}; then + echo "skipping ${principal}, already exists" + else + ${add-principal-str principal} + fi + ''; + + ensure-host-principals = realm: + concatStringsSep "\n" + (map exists-or-add + (concatMap (get-host-principals realm) + (get-domain-hosts (toLower realm)))); + + slave-hostnames = map get-fqdn cfg.master-config.slave-hosts; + + ensure-iprop-principals = concatStringsSep "\n" + (map (host: exists-or-add "iprop/${host}@${realm}") + [ local-hostname ] ++ slave-hostnames); + + copy-slave-principals-file = let + slave-principals = map + (host: "iprop/${hostname}@${cfg.realm}") + slave-hostnames; + slave-principals-file = pkgs.writeText "heimdal-slave-principals" + (concatStringsSep "\n" slave-principals); + in optionalString (slave-principals-file != null) '' + cp ${slave-principals-file} ${state-directory}/slaves + # Since it's copied from /nix/store, this is by default read-only, + # which causes updates to fail. + chmod u+w ${state-directory}/slaves + ''; + + in pkgs.writeShellScript "initialize-kdc-db.sh" '' + TMP=$(mktemp -d -t kdc-XXXXXXXX) + if [ ! -e ${database-file} ]; then + ## CHANGING HOW THIS WORKS + ## Now we expect the key to be provided + # kstash --key-file=${key-file} --random-key + ${kadmin-cmd} init --realm-max-ticket-life="${max-lifetime}" --realm-max-renewable-life="${max-renewal}" ${realm} + fi + + ${ensure-host-principals realm} + + ${ensure-iprop-principals} + + echo "*** BEGIN EXTRACTING KEYTABS" + echo "*** You can probably ignore the 'principal does not exist' errors that follow," + echo "*** they're just testing for principal existence before creating those that" + echo "*** don't already exist" + + ${kadmin-cmd} ext_keytab --keytab=$TMP/primary.keytab */${local-hostname}@${realm} + mv $TMP/primary.keytab ${primary-keytab} + ${kadmin-cmd} ext_keytab --keytab=$TMP/kadmin.keytab kadmin/admin@${realm} + mv $TMP/kadmin.keytab ${kadmin-keytab} + ${kadmin-cmd} ext_keytab --keytab=$TMP/kpasswd.keytab kadmin/changepw@${realm} + mv $TMP/kpasswd.keytab ${kpasswd-keytab} + ${kadmin-cmd} ext_keytab --keytab=$TMP/ipropd.keytab iprop/${local-hostname}@${realm} + mv $TMP/ipropd.keytab ${ipropd-keytab} + + echo "*** END EXTRACTING KEYTABS" + + ${copy-slave-principals-file} + ''; + + generate-kdc-conf = { realm, db-file, key-file, acl-data }: + pkgs.writeText "kdc.conf" '' + [kdc] + database = { + dbname = sqlite:${db-file} + realm = ${realm} + mkey_file = ${key-file} + ${optionalString (acl-data != null) + "acl_file = ${generate-acl-file acl-data}"} + log_file = ${iprop-log} + } + + [realms] + ${realm} = { + enable-http = false + } + + [logging] + kdc = FILE:${state-directory}/kerberos.log + default = FILE:${state-directory}/kerberos.log + ''; + + aclEntry = { principal, ... }: { + options = with types; { + perms = let + perms = [ + "change-password" + "add" + "list" + "delete" + "modify" + "get" + "get-keys" + "all" + ]; + in mkOption { + type = listOf (enum perms); + description = "List of permissions."; + default = [ ]; + }; + + target = mkOption { + type = nullOr str; + description = "Target principals."; + default = null; + example = "hosts/*@REALM.COM"; + }; + }; + }; + + generate-acl-file = acl-entries: let + perms-to-permstring = perms: concatStringsSep "," perms; + in + pkgs.writeText "kdc.acl" (concatStringsSep "\n" (mapAttrsToList + (principal: opts: + "${principal} ${perms-to-permstring opts.perms}${ + optionalString (opts.target != null) " ${opts.target}" }") + acl-entries)); + + kadmin-local = kdc-conf: + pkgs.writeShellScriptBin "kadmin.local" '' + ${pkgs.heimdalFull}/bin/kadmin -l -c ${kdc-conf} $@ + ''; + + masterOpts = { ... }: { + options = with types; { + acl = mkOption { + type = attrsOf (submodule aclEntry); + description = "Mapping of pricipals to a list of permissions."; + default = { "*/admin" = [ "all" ]; }; + example = { + "*/root" = [ "all" ]; + "admin-user" = [ "add" "list" "modify" ]; + }; + }; + + kadmin-keytab = mkOption { + type = str; + description = "Location at which to store keytab for kadmind."; + default = "${state-directory}/kadmind.keytab"; + }; + + kpasswdd-keytab = mkOption { + type = str; + description = "Location at which to store keytab for kpasswdd."; + default = "${state-directory}/kpasswdd.keytab"; + }; + + ipropd-keytab = mkOption { + type = str; + description = "Location at which to store keytab for ipropd master."; + default = "${state-directory}/ipropd.keytab"; + }; + + slave-hosts = mkOption { + type = listOf str; + description = '' + A list of host to which the database should be propagated. + + Must exist in the Fudo Host database. + ''; + default = [ ]; + }; + }; + }; + + slaveOpts = { ... }: { + options = with types; { + master-host = mkOption { + type = str; + description = '' + Host from which to recieve database updates. + + Must exist in the Fudo Host database. + ''; + }; + + ipropd-keytab = mkOption { + type = str; + description = "Location at which to find keytab for ipropd slave."; + default = "${state-directory}/ipropd.keytab"; + }; + }; + }; + +in { + + options.fudo.auth.kdc = with types; { + enable = mkEnableOption "Fudo KDC"; + + realm = mkOption { + type = str; + description = "The realm for which we are the acting KDC."; + }; + + bind-addresses = mkOption { + type = listOf str; + description = "A list of IP addresses on which to bind."; + default = host-ips; + }; + + user = mkOption { + type = str; + description = "User as which to run Heimdal servers."; + default = "kerberos"; + }; + + group = mkOption { + type = str; + description = "Group as which to run Heimdal servers."; + default = "kerberos"; + }; + + state-directory = mkOption { + type = str; + description = "Path at which to store kerberos database."; + default = "/var/lib/kerberos"; + }; + + master-key-file = mkOption { + type = str; + description = '' + File containing the master key for the realm. + + Must be provided! + ''; + }; + + primary-keytab = mkOption { + type = str; + description = "Location of host master keytab."; + default = "${state-directory}/host.keytab"; + }; + + master-config = mkOption { + type = nullOr (submodule masterOpts); + description = "Configuration for the master KDC server."; + default = null; + }; + + slave-config = mkOption { + type = nullOr (submodule slaveOpts); + description = "Configuration for slave KDC servers."; + default = null; + }; + + max-ticket-lifetime = mkOption { + type = str; + description = "Maximum lifetime of a single ticket in this realm."; + default = "1d"; + }; + + max-ticket-renewal = mkOption { + type = str; + description = "Maximum time a ticket may be renewed in this realm."; + default = "7d"; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = master-server || slave-server; + message = + "For the KDC to be enabled, a master OR slave config must be provided."; + } + { + assertion = !(master-server && slave-server); + message = + "Only one of master-config and slave-config may be provided."; + } + ]; + + users = { + users.${cfg.user} = { + isSystemUser = true; + home = state-directory; + group = cfg.group; + }; + + groups.${cfg.group} = { members = [ cfg.user ]; }; + }; + + krb5 = { + libdefaults = { + # Stick to ~/.k5login + # k5login_directory = cfg.k5login-directory; + ticket_lifetime = cfg.max-ticket-lifetime; + renew_lifetime = cfg.max-ticket-renewal; + }; + # Sorry, port 80 isn't available! + realms.${cfg.realm}.enable-http = false; + extraConfig = '' + default = FILE:${state-directory}/kerberos.log + ''; + }; + + environment = { + systemPackages = [ pkgs.heimdalFull (kadmin-local kdc-conf) ]; + + ## This shouldn't be necessary...every host gets a krb5.keytab + # etc = { + # "krb5.keytab" = { + # user = "root"; + # group = "root"; + # mode = "0400"; + # source = cfg.primary-keytab; + # }; + # }; + }; + + fudo.system = { + ensure-directories = { + "${state-directory}" = { + user = cfg.user; + group = cfg.group; + perms = "0740"; + }; + }; + + services = if master-server then { + + heimdal-kdc = let + listen-addrs = concatStringsSep " " + (map (addr: "--addresses=${addr}") cfg.bind-addresses); + in { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + description = + "Heimdal Kerberos Key Distribution Center (ticket server)."; + execStart = "${pkgs.heimdalFull}/libexec/heimdal/kdc -c ${kdc-conf} --ports=88 ${listen-addrs}"; + user = cfg.user; + group = cfg.group; + workingDirectory = state-directory; + privateNetwork = false; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + requiredCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + environment = { KRB5_CONFIG = "/etc/krb5.conf"; }; + }; + + heimdal-kdc-init = let + init-cmd = initialize-db { + realm = cfg.realm; + user = cfg.user; + group = cfg.group; + kdc-conf = kdc-conf; + key-file = cfg.master-key-file; + db-name = database-file; + max-lifetime = cfg.max-ticket-lifetime; + max-renewal = cfg.max-ticket-renewal; + primary-keytab = cfg.primary-keytab; + kadmin-keytab = cfg.master-config.kadmin-keytab; + kpasswd-keytab = cfg.master-config.kpasswdd-keytab; + ipropd-keytab = cfg.master-config.ipropd-keytab; + local-hostname = + "${config.instance.hostname}.${config.instance.local-domain}"; + }; + in { + requires = [ "heimdal-kdc.service" ]; + wantedBy = [ "multi-user.target" ]; + description = "Initialization script for Heimdal KDC."; + type = "oneshot"; + execStart = "${init-cmd}"; + user = cfg.user; + group = cfg.group; + path = with pkgs; [ heimdalFull ]; + protectSystem = "full"; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + workingDirectory = state-directory; + environment = { KRB5_CONFIG = "/etc/krb5.conf"; }; + }; + + heimdal-ipropd-master = mkIf (length cfg.master-config.slave-hosts > 0) { + requires = [ "heimdal-kdc.service" ]; + wantedBy = [ "multi-user.target" ]; + description = "Propagate changes to the master KDC DB to all slaves."; + path = with pkgs; [ heimdalFull ]; + execStart = "${pkgs.heimdalFull}/libexec/heimdal/ipropd-master -c ${kdc-conf} -k ${cfg.master.ipropd-keytab}"; + user = cfg.user; + group = cfg.group; + workingDirectory = state-directory; + privateNetwork = false; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + environment = { KRB5_CONFIG = "/etc/krb5.conf"; }; + }; + + } else { + + heimdal-kdc-slave = let + listen-addrs = concatStringsSep " " + (map (addr: "--addresses=${addr}") cfg.bind-addresses); + command = + "${pkgs.heimdalFull}/libexec/heimdal/kdc -c ${kdc-conf} --ports=88 ${listen-addrs}"; + in { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + description = + "Heimdal Slave Kerberos Key Distribution Center (ticket server)."; + execStart = command; + user = cfg.user; + group = cfg.group; + workingDirectory = state-directory; + privateNetwork = false; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + requiredCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + environment = { KRB5_CONFIG = "/etc/krb5.conf"; }; + }; + + heimdal-ipropd-slave = { + wantedBy = [ "multi-user.target" ]; + description = "Receive changes propagated from the KDC master server."; + path = with pkgs; [ heimdalFull ]; + execStart = concatStringsSep " " [ + "${pkgs.heimdalFull}/libexec/heimdal/ipropd-slave" + "--config-file=${kdc-conf}" + "--keytab=${cfg.slave-config.ipropd-keytab}" + "--realm=${cfg.realm}" + "--hostname=${get-fqdn hostname}" + "--port=2121" + "--verbose" + (get-fqdn cfg.slave-config.master-host) + ]; + user = cfg.user; + group = cfg.group; + workingDirectory = state-directory; + privateNetwork = false; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + requiredCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + environment = { KRB5_CONFIG = "/etc/krb5.conf"; }; + }; + }; + }; + + services.xinetd = mkIf master-server { + enable = true; + + services = [ + { + name = "kerberos-adm"; + user = cfg.user; + server = "${pkgs.heimdalFull}/libexec/heimdal/kadmind"; + protocol = "tcp"; + serverArgs = + "--config-file=${kdc-conf} --keytab=${cfg.master-config.kadmin-keytab}"; + } + { + name = "kpasswd"; + user = cfg.user; + server = "${pkgs.heimdalFull}/libexec/heimdal/kpasswdd"; + protocol = "udp"; + serverArgs = + "--config-file=${kdc-conf} --keytab=${cfg.master-config.kpasswdd-keytab}"; + } + ]; + }; + + networking = { + firewall = { + allowedTCPPorts = [ 88 ] ++ + (optionals master-server [ 749 ]) ++ + (optionals slave-server [ 2121 ]); + allowedUDPPorts = [ 88 ] ++ + (optionals master-server [ 464 ]) ++ + (optionals slave-server [ 2121 ]); + }; + }; + }; +} diff --git a/lib/fudo/ldap.nix b/lib/fudo/ldap.nix new file mode 100644 index 0000000..ebb4bab --- /dev/null +++ b/lib/fudo/ldap.nix @@ -0,0 +1,460 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + + cfg = config.fudo.auth.ldap-server; + + user-type = import ../types/user.nix { inherit lib; }; + + stringJoin = concatStringsSep; + + getUserGidNumber = user: group-map: group-map.${user.primary-group}.gid; + + attrOr = attrs: attr: value: if attrs ? ${attr} then attrs.${attr} else value; + + ca-path = "${cfg.state-directory}/ca.pem"; + + build-ca-script = target: ca-cert: site-chain: let + user = config.services.openldap.user; + group = config.services.openldap.group; + in pkgs.writeShellScript "build-openldap-ca-script.sh" '' + cat ${site-chain} ${ca-cert} > ${target} + chmod 440 ${target} + chown ${user}:${group} ${target} + ''; + + mkHomeDir = username: user-opts: + if (user-opts.primary-group == "admin") then + "/home/${username}" + else + "/home/${user-opts.primary-group}/${username}"; + + userLdif = base: name: group-map: opts: '' + dn: uid=${name},ou=members,${base} + uid: ${name} + objectClass: account + objectClass: shadowAccount + objectClass: posixAccount + cn: ${opts.common-name} + uidNumber: ${toString (opts.uid)} + gidNumber: ${toString (getUserGidNumber opts group-map)} + homeDirectory: ${mkHomeDir name opts} + description: ${opts.description} + shadowLastChange: 12230 + shadowMax: 99999 + shadowWarning: 7 + userPassword: ${opts.ldap-hashed-passwd} + ''; + + systemUserLdif = base: name: opts: '' + dn: cn=${name},${base} + objectClass: organizationalRole + objectClass: simpleSecurityObject + cn: ${name} + description: ${opts.description} + userPassword: ${opts.ldap-hashed-password} + ''; + + toMemberList = userList: + stringJoin "\n" (map (username: "memberUid: ${username}") userList); + + groupLdif = base: name: opts: '' + dn: cn=${name},ou=groups,${base} + objectClass: posixGroup + cn: ${name} + gidNumber: ${toString (opts.gid)} + description: ${opts.description} + ${toMemberList opts.members} + ''; + + systemUsersLdif = base: user-map: + stringJoin "\n" + (mapAttrsToList (name: opts: systemUserLdif base name opts) user-map); + + groupsLdif = base: group-map: + stringJoin "\n" + (mapAttrsToList (name: opts: groupLdif base name opts) group-map); + + usersLdif = base: group-map: user-map: + stringJoin "\n" + (mapAttrsToList (name: opts: userLdif base name group-map opts) user-map); + +in { + + options = with types; { + fudo = { + auth = { + ldap-server = { + enable = mkEnableOption "Fudo Authentication"; + + kerberos-host = mkOption { + type = str; + description = '' + The name of the host to use for Kerberos authentication. + ''; + }; + + kerberos-keytab = mkOption { + type = str; + description = '' + The path to a keytab for the LDAP server, containing a principal for ldap/. + ''; + }; + + ssl-certificate = mkOption { + type = str; + description = '' + The path to the SSL certificate to use for the server. + ''; + }; + + ssl-chain = mkOption { + type = str; + description = '' + The path to the SSL chain to to the certificate for the server. + ''; + }; + + ssl-private-key = mkOption { + type = str; + description = '' + The path to the SSL key to use for the server. + ''; + }; + + ssl-ca-certificate = mkOption { + type = nullOr str; + description = '' + The path to the SSL CA cert used to sign the certificate. + ''; + default = null; + }; + + organization = mkOption { + type = str; + description = '' + The name to use for the organization. + ''; + }; + + base = mkOption { + type = str; + description = "The base dn of the LDAP server."; + example = "dc=fudo,dc=org"; + }; + + rootpw-file = mkOption { + default = ""; + type = str; + description = '' + The path to a file containing the root password for this database. + ''; + }; + + listen-uris = mkOption { + type = listOf str; + description = '' + A list of URIs on which the ldap server should listen. + ''; + example = [ "ldap://auth.fudo.org" "ldaps://auth.fudo.org" ]; + }; + + users = mkOption { + type = attrsOf (submodule user-type.userOpts); + example = { + tester = { + uid = 10099; + common-name = "Joe Blow"; + hashed-password = ""; + }; + }; + description = '' + Users to be added to the Fudo LDAP database. + ''; + default = { }; + }; + + groups = mkOption { + default = { }; + type = attrsOf (submodule user-type.groupOpts); + example = { + admin = { + gid = 1099; + members = [ "tester" ]; + }; + }; + description = '' + Groups to be added to the Fudo LDAP database. + ''; + }; + + system-users = mkOption { + default = { }; + type = attrsOf (submodule user-type.systemUserOpts); + example = { + replicator = { + description = "System user for database sync"; + ldap-hashed-password = ""; + }; + }; + description = "System users to be added to the Fudo LDAP database."; + }; + + state-directory = mkOption { + type = str; + description = "Path at which to store openldap database & state."; + }; + + systemd-target = mkOption { + type = str; + description = "Systemd target for running ldap server."; + default = "fudo-ldap-server.target"; + }; + + required-services = mkOption { + type = listOf str; + description = "Systemd services on which the server depends."; + default = [ ]; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + + environment = { + etc = { + "openldap/sasl2/slapd.conf" = { + mode = "0400"; + user = config.services.openldap.user; + group = config.services.openldap.group; + text = '' + mech_list: gssapi external + keytab: ${cfg.kerberos-keytab} + ''; + }; + }; + }; + + networking.firewall = { + allowedTCPPorts = [ 389 636 ]; + allowedUDPPorts = [ 389 ]; + }; + + systemd = { + tmpfiles.rules = let + ca-dir = dirOf ca-path; + user = config.services.openldap.user; + group = config.services.openldap.group; + in [ + "d ${ca-dir} 0700 ${user} ${group} - -" + ]; + + services.openldap = { + partOf = [ cfg.systemd-target ]; + requires = cfg.required-services; + environment.KRB5_KTNAME = cfg.kerberos-keytab; + preStart = mkBefore + "${build-ca-script ca-path + cfg.ssl-chain + cfg.ssl-ca-certificate}"; + serviceConfig = { + PrivateDevices = true; + PrivateTmp = true; + PrivateMounts = true; + ProtectControlGroups = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectSystem = true; + ProtectHostname = true; + ProtectHome = true; + ProtectClock = true; + ProtectKernelLogs = true; + KeyringMode = "private"; + # RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + Restart = "on-failure"; + LockPersonality = true; + RestrictRealtime = true; + MemoryDenyWriteExecute = true; + SystemCallFilter = concatStringsSep " " [ + "~@clock" + "@debug" + "@module" + "@mount" + "@raw-io" + "@reboot" + "@swap" + # "@privileged" + "@resources" + "@cpu-emulation" + "@obsolete" + ]; + UMask = "7007"; + InaccessiblePaths = [ "/home" "/root" ]; + LimitNOFILE = 49152; + PermissionsStartOnly = true; + }; + }; + }; + + services.openldap = { + enable = true; + urlList = cfg.listen-uris; + + settings = let + makePermEntry = dn: perm: "by ${dn} ${perm}"; + + makeAccessLine = target: perm-map: let + perm-entries = mapAttrsToList makePermEntry perm-map; + in "to ${target} ${concatStringsSep " " perm-entries}"; + + makeAccess = access-map: let + access-lines = mapAttrsToList makeAccessLine; + numbered-access-lines = imap0 (i: line: "{${toString i}}${line}"); + in numbered-access-lines (access-lines access-map); + + in { + attrs = { + cn = "config"; + objectClass = "olcGlobal"; + olcPidFile = "/run/slapd/slapd.pid"; + olcTLSCertificateFile = cfg.ssl-certificate; + olcTLSCertificateKeyFile = cfg.ssl-private-key; + olcTLSCACertificateFile = ca-path; + olcSaslSecProps = "noplain,noanonymous"; + olcAuthzRegexp = let + authz-regex-entry = i: { regex, target }: + "{${toString i}}\"${regex}\" \"${target}\""; + in imap0 authz-regex-entry [ + { + regex = "^uid=auth/([^.]+).fudo.org,cn=fudo.org,cn=gssapi,cn=auth$"; + target = "cn=$1,ou=hosts,dc=fudo,dc=org"; + } + { + regex = "^uid=[^,/]+/root,cn=fudo.org,cn=gssapi,cn=auth$"; + target = "cn=admin,dc=fudo,dc=org"; + } + { + regex = "^uid=([^,/]+),cn=fudo.org,cn=gssapi,cn=auth$"; + target = "uid=$1,ou=members,dc=fudo,dc=org"; + } + { + regex = "^uid=host/([^,/]+),cn=fudo.org,cn=gssapi,cn=auth$"; + target = "cn=$1,ou=hosts,dc=fudo,dc=org"; + } + { + regex = "^gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth$"; + target = "cn=admin,dc=fudo,dc=org"; + } + ]; + }; + children = { + "cn=schema" = { + includes = [ + "${pkgs.openldap}/etc/schema/core.ldif" + "${pkgs.openldap}/etc/schema/cosine.ldif" + "${pkgs.openldap}/etc/schema/inetorgperson.ldif" + "${pkgs.openldap}/etc/schema/nis.ldif" + ]; + }; + "olcDatabase={-1}frontend" = { + attrs = { + objectClass = [ "olcDatabaseConfig" "olcFrontendConfig" ]; + olcDatabase = "{-1}frontend"; + olcAccess = makeAccess { + "*" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "*" = "none"; + }; + }; + }; + }; + "olcDatabase={0}config" = { + attrs = { + objectClass = [ "olcDatabaseConfig" ]; + olcDatabase = "{0}config"; + olcAccess = makeAccess { + "*" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "*" = "none"; + }; + }; + }; + }; + "olcDatabase={1}mdb" = { + attrs = { + objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ]; + olcDatabase = "{1}mdb"; + olcSuffix = cfg.base; + # olcRootDN = "cn=admin,${cfg.base}"; + # olcRootPW = FIXME; # NOTE: this should be hashed... + olcDbDirectory = "${cfg.state-directory}/database"; + olcDbIndex = [ "objectClass eq" "uid eq" ]; + olcAccess = makeAccess { + "attrs=userPassword,shadowLastChange" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "dn.exact=cn=auth_reader,${cfg.base}" = "read"; + "dn.exact=cn=replicator,${cfg.base}" = "read"; + "self" = "write"; + "*" = "auth"; + }; + "dn=cn=admin,ou=groups,${cfg.base}" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "users" = "read"; + "*" = "none"; + }; + "dn.subtree=ou=groups,${cfg.base} attrs=memberUid" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "dn.regex=cn=[a-zA-Z][a-zA-Z0-9_]+,ou=hosts,${cfg.base}" = "write"; + "users" = "read"; + "*" = "none"; + }; + "dn.subtree=ou=members,${cfg.base} attrs=cn,sn,homeDirectory,loginShell,gecos,description,homeDirectory,uidNumber,gidNumber" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "dn.exact=cn=user_db_reader,${cfg.base}" = "read"; + "users" = "read"; + "*" = "none"; + }; + "*" = { + "dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" = "manage"; + "users" = "read"; + "*" = "none"; + }; + }; + }; + }; + }; + }; + + declarativeContents = { + "dc=fudo,dc=org" = '' + dn: ${cfg.base} + objectClass: top + objectClass: dcObject + objectClass: organization + o: ${cfg.organization} + + dn: ou=groups,${cfg.base} + objectClass: organizationalUnit + description: ${cfg.organization} groups + + dn: ou=members,${cfg.base} + objectClass: organizationalUnit + description: ${cfg.organization} members + + dn: cn=admin,${cfg.base} + objectClass: organizationalRole + cn: admin + description: "Admin User" + + ${systemUsersLdif cfg.base cfg.system-users} + ${groupsLdif cfg.base cfg.groups} + ${usersLdif cfg.base cfg.groups cfg.users} + ''; + }; + }; + }; +} diff --git a/lib/fudo/local-network.nix b/lib/fudo/local-network.nix new file mode 100644 index 0000000..cbb7f44 --- /dev/null +++ b/lib/fudo/local-network.nix @@ -0,0 +1,238 @@ +{ lib, config, pkgs, ... }: + +with lib; +let + cfg = config.fudo.local-network; + + join-lines = concatStringsSep "\n"; + + traceout = out: builtins.trace out out; + +in { + + options.fudo.local-network = with types; { + + enable = mkEnableOption "Enable local network configuration (DHCP & DNS)."; + + domain = mkOption { + type = str; + description = "The domain to use for the local network."; + }; + + dns-servers = mkOption { + type = listOf str; + description = "A list of domain name servers to pass to local clients."; + }; + + dhcp-interfaces = mkOption { + type = listOf str; + description = "A list of interfaces on which to serve DHCP."; + }; + + dns-listen-ips = mkOption { + type = listOf str; + description = "A list of IPs on which to server DNS queries."; + }; + + gateway = mkOption { + type = str; + description = "The gateway to use for the local network."; + }; + + network = mkOption { + type = str; + description = "Network to treat as local."; + example = "10.0.0.0/16"; + }; + + dhcp-dynamic-network = mkOption { + type = str; + description = '' + The network from which to dynamically allocate IPs via DHCP. + + Must be a subnet of . + ''; + example = "10.0.1.0/24"; + }; + + enable-reverse-mappings = mkOption { + type = bool; + description = "Genereate PTR reverse lookup records."; + default = false; + }; + + recursive-resolver = mkOption { + type = str; + description = "DNS nameserver to use for recursive resolution."; + default = "1.1.1.1 port 53"; + }; + + search-domains = mkOption { + type = listOf str; + description = "A list of domains which clients should consider local."; + example = [ "my-domain.com" "other-domain.com" ]; + default = [ ]; + }; + + network-definition = let + networkOpts = import ../types/network-definition.nix { inherit lib; }; + in mkOption { + type = submodule networkOpts; + description = "Definition of network to be served by local server."; + default = { }; + }; + + extra-records = mkOption { + type = listOf str; + description = "Extra records to add to the local zone."; + default = [ ]; + }; + }; + + config = mkIf cfg.enable { + + fudo.system.hostfile-entries = let + other-hosts = filterAttrs + (hostname: hostOpts: hostname != config.instance.hostname) + cfg.network-definition.hosts; + in mapAttrs' (hostname: hostOpts: + nameValuePair hostOpts.ipv4-address ["${hostname}.${cfg.domain}" hostname]) + other-hosts; + + services.dhcpd4 = let network = cfg.network-definition; + in { + enable = true; + + machines = mapAttrsToList (hostname: hostOpts: { + ethernetAddress = hostOpts.mac-address; + hostName = hostname; + ipAddress = hostOpts.ipv4-address; + }) (filterAttrs (host: hostOpts: + hostOpts.mac-address != null && hostOpts.ipv4-address != null) + network.hosts); + + interfaces = cfg.dhcp-interfaces; + + extraConfig = '' + subnet ${pkgs.lib.fudo.ip.getNetworkBase cfg.network} netmask ${ + pkgs.lib.fudo.ip.maskFromV32Network cfg.network + } { + authoritative; + option subnet-mask ${pkgs.lib.fudo.ip.maskFromV32Network cfg.network}; + option broadcast-address ${pkgs.lib.fudo.ip.networkMaxIp cfg.network}; + option routers ${cfg.gateway}; + option domain-name-servers ${concatStringsSep " " cfg.dns-servers}; + option domain-name "${cfg.domain}"; + option domain-search "${ + concatStringsSep " " ([ cfg.domain ] ++ cfg.search-domains) + }"; + range ${pkgs.lib.fudo.ip.networkMinIp cfg.dhcp-dynamic-network} ${ + pkgs.lib.fudo.ip.networkMaxButOneIp cfg.dhcp-dynamic-network + }; + } + ''; + }; + + services.bind = let + blockHostsToZone = block: hosts-data: { + master = true; + name = "${block}.in-addr.arpa"; + file = let + # We should add these...but need a domain to assign them to. + # ip-last-el = ip: toInt (last (splitString "." ip)); + # used-els = map (host-data: ip-last-el host-data.ipv4-address) hosts-data; + # unused-els = subtractLists used-els (map toString (range 1 255)); + + in pkgs.writeText "db.${block}-zone" '' + $ORIGIN ${block}.in-addr.arpa. + $TTL 1h + + @ IN SOA ns1.${cfg.domain}. hostmaster.${cfg.domain}. ( + ${toString config.instance.build-timestamp} + 1800 + 900 + 604800 + 1800) + + @ IN NS ns1.${cfg.domain}. + + ${join-lines (map hostPtrRecord hosts-data)} + ''; + }; + + ipToBlock = ip: + concatStringsSep "." (reverseList (take 3 (splitString "." ip))); + compactHosts = + mapAttrsToList (host: data: data // { host = host; }) network.hosts; + hostsByBlock = + groupBy (host-data: ipToBlock host-data.ipv4-address) compactHosts; + hostPtrRecord = host-data: + "${ + last (splitString "." host-data.ipv4-address) + } IN PTR ${host-data.host}.${cfg.domain}."; + + blockZones = mapAttrsToList blockHostsToZone hostsByBlock; + + hostARecord = host: data: "${host} IN A ${data.ipv4-address}"; + hostSshFpRecords = host: data: + let + ssh-fingerprints = if (hasAttr host known-hosts) then + known-hosts.${host}.ssh-fingerprints + else + [ ]; + in join-lines + (map (sshfp: "${host} IN SSHFP ${sshfp}") ssh-fingerprints); + cnameRecord = alias: host: "${alias} IN CNAME ${host}"; + + network = cfg.network-definition; + + known-hosts = config.fudo.hosts; + + in { + enable = true; + cacheNetworks = [ cfg.network "localhost" "localnets" ]; + forwarders = [ cfg.recursive-resolver ]; + listenOn = cfg.dns-listen-ips; + extraOptions = concatStringsSep "\n" [ + "dnssec-enable yes;" + "dnssec-validation yes;" + "auth-nxdomain no;" + "recursion yes;" + "allow-recursion { any; };" + ]; + zones = [{ + master = true; + name = cfg.domain; + file = pkgs.writeText "${cfg.domain}-zone" '' + @ IN SOA ns1.${cfg.domain}. hostmaster.${cfg.domain}. ( + ${toString config.instance.build-timestamp} + 5m + 2m + 6w + 5m) + + $TTL 1h + + @ IN NS ns1.${cfg.domain}. + + $ORIGIN ${cfg.domain}. + + $TTL 30m + + ${optionalString (network.gssapi-realm != null) + ''_kerberos IN TXT "${network.gssapi-realm}"''} + + ${join-lines + (imap1 (i: server-ip: "ns${toString i} IN A ${server-ip}") + cfg.dns-servers)} + ${join-lines (mapAttrsToList hostARecord network.hosts)} + ${join-lines (mapAttrsToList hostSshFpRecords network.hosts)} + ${join-lines (mapAttrsToList cnameRecord network.aliases)} + ${join-lines network.verbatim-dns-records} + ${pkgs.lib.fudo.dns.srvRecordsToBindZone network.srv-records} + ${join-lines cfg.extra-records} + ''; + }] ++ blockZones; + }; + }; +} diff --git a/lib/fudo/mail-container.nix b/lib/fudo/mail-container.nix new file mode 100644 index 0000000..8a890ab --- /dev/null +++ b/lib/fudo/mail-container.nix @@ -0,0 +1,221 @@ +{ pkgs, lib, config, ... }: +with lib; +let + hostname = config.instance.hostname; + cfg = config.fudo.mail-server; + container-maildir = "/var/lib/mail"; + container-statedir = "/var/lib/mail-state"; + + # Don't bother with group-id, nixos doesn't seem to use it anyway + container-mail-user = "mailer"; + container-mail-user-id = 542; + container-mail-group = "mailer"; + + build-timestamp = config.instance.build-timestamp; + build-seed = config.instance.build-seed; + site = config.instance.local-site; + domain = cfg.domain; + + local-networks = config.instance.local-networks; + +in rec { + config = mkIf (cfg.enableContainer) { + # Disable postfix on this host--it'll be run in the container instead + services.postfix.enable = false; + + services.nginx = mkIf cfg.monitoring { + enable = true; + + virtualHosts = let + proxy-headers = '' + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + ''; + trusted-network-string = + optionalString ((length local-networks) > 0) + (concatStringsSep "\n" + (map (network: "allow ${network};") + local-networks)) + '' + + deny all;''; + + in { + "${cfg.mail-hostname}" = { + enableACME = true; + forceSSL = true; + + locations."/metrics/postfix" = { + proxyPass = "http://127.0.0.1:9154/metrics"; + + extraConfig = '' + ${proxy-headers} + + ${trusted-network-string} + ''; + }; + + locations."/metrics/dovecot" = { + proxyPass = "http://127.0.0.1:9166/metrics"; + + extraConfig = '' + ${proxy-headers} + + ${trusted-network-string} + ''; + }; + + locations."/metrics/rspamd" = { + proxyPass = "http://127.0.0.1:7980/metrics"; + + extraConfig = '' + ${proxy-headers} + + ${trusted-network-string} + ''; + }; + }; + }; + }; + + containers.mail-server = { + + autoStart = true; + + bindMounts = { + "${container-maildir}" = { + hostPath = cfg.mail-directory; + isReadOnly = false; + }; + + "${container-statedir}" = { + hostPath = cfg.state-directory; + isReadOnly = false; + }; + + "/run/mail/certs/postfix/cert.pem" = { + hostPath = cfg.ssl.certificate; + isReadOnly = true; + }; + + "/run/mail/certs/postfix/key.pem" = { + hostPath = cfg.ssl.private-key; + isReadOnly = true; + }; + + "/run/mail/certs/dovecot/cert.pem" = { + hostPath = cfg.ssl.certificate; + isReadOnly = true; + }; + + "/run/mail/certs/dovecot/key.pem" = { + hostPath = cfg.ssl.private-key; + isReadOnly = true; + }; + + "/run/mail/passwords/dovecot/ldap-reader.passwd" = { + hostPath = cfg.dovecot.ldap.reader-password-file; + isReadOnly = true; + }; + }; + + config = { config, pkgs, ... }: { + + imports = let + initialize-host = import ../../initialize.nix; + profile = "container"; + in [ + ./mail.nix + + (initialize-host { + inherit + lib + pkgs + build-timestamp + site + domain + profile; + hostname = "mail-container"; + }) + ]; + + instance.build-seed = build-seed; + + environment.etc = { + "mail-server/postfix/cert.pem" = { + source = "/run/mail/certs/postfix/cert.pem"; + user = config.services.postfix.user; + mode = "0444"; + }; + "mail-server/postfix/key.pem" = { + source = "/run/mail/certs/postfix/key.pem"; + user = config.services.postfix.user; + mode = "0400"; + }; + "mail-server/dovecot/cert.pem" = { + source = "/run/mail/certs/dovecot/cert.pem"; + user = config.services.dovecot2.user; + mode = "0444"; + }; + "mail-server/dovecot/key.pem" = { + source = "/run/mail/certs/dovecot/key.pem"; + user = config.services.dovecot2.user; + mode = "0400"; + }; + + ## The pre-script runs as root anyway... + # "mail-server/dovecot/ldap-reader.passwd" = { + # source = "/run/mail/passwords/dovecot/ldap-reader.passwd"; + # user = config.services.dovecot2.user; + # mode = "0400"; + # }; + }; + + fudo = { + + mail-server = { + enable = true; + mail-hostname = cfg.mail-hostname; + domain = cfg.domain; + + debug = cfg.debug; + monitoring = cfg.monitoring; + + state-directory = container-statedir; + mail-directory = container-maildir; + + postfix = { + ssl-certificate = "/etc/mail-server/postfix/cert.pem"; + ssl-private-key = "/etc/mail-server/postfix/key.pem"; + }; + + dovecot = { + ssl-certificate = "/etc/mail-server/dovecot/cert.pem"; + ssl-private-key = "/etc/mail-server/dovecot/key.pem"; + ldap = { + server-urls = cfg.dovecot.ldap.server-urls; + reader-dn = cfg.dovecot.ldap.reader-dn; + reader-password-file = "/run/mail/passwords/dovecot/ldap-reader.passwd"; + }; + }; + + local-domains = cfg.local-domains; + + alias-users = cfg.alias-users; + user-aliases = cfg.user-aliases; + sender-blacklist = cfg.sender-blacklist; + recipient-blacklist = cfg.recipient-blacklist; + trusted-networks = cfg.trusted-networks; + + mail-user = container-mail-user; + mail-user-id = container-mail-user-id; + mail-group = container-mail-group; + + clamav.enable = cfg.clamav.enable; + + dkim.signing = cfg.dkim.signing; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/mail.nix b/lib/fudo/mail.nix new file mode 100644 index 0000000..e31d948 --- /dev/null +++ b/lib/fudo/mail.nix @@ -0,0 +1,225 @@ +{ config, lib, pkgs, environment, ... }: + +with lib; +let + inherit (lib.strings) concatStringsSep; + cfg = config.fudo.mail-server; + +in { + + options.fudo.mail-server = with types; { + enable = mkEnableOption "Fudo Email Server"; + + enableContainer = mkEnableOption '' + Run the mail server in a container. + + Mutually exclusive with mail-server.enable. + ''; + + domain = mkOption { + type = str; + description = "The main and default domain name for this email server."; + }; + + mail-hostname = mkOption { + type = str; + description = "The domain name to use for the mail server."; + }; + + ldap-url = mkOption { + type = str; + description = "URL of the LDAP server to use for authentication."; + example = "ldaps://auth.fudo.org/"; + }; + + monitoring = mkEnableOption "Enable monitoring for the mail server."; + + mail-user = mkOption { + type = str; + description = "User to use for mail delivery."; + default = "mailuser"; + }; + + # No group id, because NixOS doesn't seem to use it + mail-group = mkOption { + type = str; + description = "Group to use for mail delivery."; + default = "mailgroup"; + }; + + mail-user-id = mkOption { + type = int; + description = "UID of mail-user."; + }; + + local-domains = mkOption { + type = listOf str; + description = "A list of domains for which we accept mail."; + default = ["localhost" "localhost.localdomain"]; + example = [ + "localhost" + "localhost.localdomain" + "somedomain.com" + "otherdomain.org" + ]; + }; + + mail-directory = mkOption { + type = str; + description = "Path to use for mail storage."; + }; + + state-directory = mkOption { + type = str; + description = "Path to use for state data."; + }; + + trusted-networks = mkOption { + type = listOf str; + description = "A list of trusted networks, for which we will happily relay without auth."; + example = [ + "10.0.0.0/16" + "192.168.0.0/24" + ]; + }; + + sender-blacklist = mkOption { + type = listOf str; + description = "A list of email addresses for whom we will not send email."; + default = []; + example = [ + "baduser@test.com" + "change-pw@test.com" + ]; + }; + + recipient-blacklist = mkOption { + type = listOf str; + description = "A list of email addresses for whom we will not accept email."; + default = []; + example = [ + "baduser@test.com" + "change-pw@test.com" + ]; + }; + + message-size-limit = mkOption { + type = int; + description = "Size of max email in megabytes."; + default = 30; + }; + + user-aliases = mkOption { + type = attrsOf (listOf str); + description = "A map of real user to list of alias emails."; + default = {}; + example = { + someuser = ["alias0" "alias1"]; + }; + }; + + alias-users = mkOption { + type = attrsOf (listOf str); + description = "A map of email alias to a list of users."; + example = { + alias = ["realuser0" "realuser1"]; + }; + }; + + mailboxes = mkOption { + description = '' + The mailboxes for dovecot. + + Depending on the mail client used it might be necessary to change some mailbox's name. + ''; + default = { + Trash = { + auto = "create"; + specialUse = "Trash"; + autoexpunge = "30d"; + }; + Junk = { + auto = "create"; + specialUse = "Junk"; + autoexpunge = "60d"; + }; + Drafts = { + auto = "create"; + specialUse = "Drafts"; + autoexpunge = "60d"; + }; + Sent = { + auto = "subscribe"; + specialUse = "Sent"; + }; + Archive = { + auto = "no"; + specialUse = "Archive"; + }; + Flagged = { + auto = "no"; + specialUse = "Flagged"; + }; + }; + }; + + debug = mkOption { + description = "Enable debugging on mailservers."; + type = bool; + default = false; + }; + + max-user-connections = mkOption { + description = "Max simultaneous connections per user."; + type = int; + default = 20; + }; + + ssl = { + certificate = mkOption { + type = str; + description = "Path to the ssl certificate for the mail server to use."; + }; + + private-key = mkOption { + type = str; + description = "Path to the ssl private key for the mail server to use."; + }; + }; + }; + + imports = [ + ./mail/dkim.nix + ./mail/dovecot.nix + ./mail/postfix.nix + ./mail/rspamd.nix + ./mail/clamav.nix + ]; + + config = mkIf cfg.enable { + systemd.tmpfiles.rules = [ + "d ${cfg.mail-directory} 775 ${cfg.mail-user} ${cfg.mail-group} - -" + "d ${cfg.state-directory} 775 root ${cfg.mail-group} - -" + ]; + + networking.firewall = { + allowedTCPPorts = [ 25 110 143 587 993 995 ]; + }; + + users = { + users = { + ${cfg.mail-user} = { + isSystemUser = true; + uid = cfg.mail-user-id; + group = cfg.mail-group; + }; + }; + + groups = { + ${cfg.mail-group} = { + members = [ cfg.mail-user ]; + }; + }; + }; + }; +} diff --git a/lib/fudo/mail/clamav.nix b/lib/fudo/mail/clamav.nix new file mode 100644 index 0000000..455548c --- /dev/null +++ b/lib/fudo/mail/clamav.nix @@ -0,0 +1,25 @@ +{ config, pkgs, lib, ... }: + +with lib; +let cfg = config.fudo.mail-server; + +in { + options.fudo.mail-server.clamav = { + enable = mkOption { + description = "Enable virus scanning with ClamAV."; + type = types.bool; + default = true; + }; + }; + + config = mkIf (cfg.enable && cfg.clamav.enable) { + + services.clamav = { + daemon = { + enable = true; + settings = { PhishingScanURLs = "no"; }; + }; + updater.enable = true; + }; + }; +} diff --git a/lib/fudo/mail/dkim.nix b/lib/fudo/mail/dkim.nix new file mode 100644 index 0000000..3e0cb48 --- /dev/null +++ b/lib/fudo/mail/dkim.nix @@ -0,0 +1,114 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.fudo.mail-server; + + createDomainDkimCert = dom: + let + dkim_key = "${cfg.dkim.key-directory}/${dom}.${cfg.dkim.selector}.key"; + dkim_txt = "${cfg.dkim.key-directory}/${dom}.${cfg.dkim.selector}.txt"; + in + '' + if [ ! -f "${dkim_key}" ] || [ ! -f "${dkim_txt}" ] + then + ${cfg.dkim.package}/bin/opendkim-genkey -s "${cfg.dkim.selector}" \ + -d "${dom}" \ + --bits="${toString cfg.dkim.key-bits}" \ + --directory="${cfg.dkim.key-directory}" + mv "${cfg.dkim.key-directory}/${cfg.dkim.selector}.private" "${dkim_key}" + mv "${cfg.dkim.key-directory}/${cfg.dkim.selector}.txt" "${dkim_txt}" + echo "Generated key for domain ${dom} selector ${cfg.dkim.selector}" + fi + ''; + + createAllCerts = lib.concatStringsSep "\n" (map createDomainDkimCert cfg.local-domains); + + keyTable = pkgs.writeText "opendkim-KeyTable" + (lib.concatStringsSep "\n" (lib.flip map cfg.local-domains + (dom: "${dom} ${dom}:${cfg.dkim.selector}:${cfg.dkim.key-directory}/${dom}.${cfg.dkim.selector}.key"))); + signingTable = pkgs.writeText "opendkim-SigningTable" + (lib.concatStringsSep "\n" (lib.flip map cfg.local-domains (dom: "${dom} ${dom}"))); + + dkim = config.services.opendkim; + args = [ "-f" "-l" ] ++ lib.optionals (dkim.configFile != null) [ "-x" dkim.configFile ]; +in +{ + + options.fudo.mail-server.dkim = { + signing = mkOption { + type = types.bool; + default = true; + description = "Enable dkim signatures for mail."; + }; + + key-directory = mkOption { + type = types.str; + default = "/var/dkim"; + description = "Path to use to store DKIM keys."; + }; + + selector = mkOption { + type = types.str; + default = "mail"; + description = "Name to use for mail-signing keys."; + }; + + key-bits = mkOption { + type = types.int; + default = 2048; + description = '' + How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. + + If you have already deployed a key with a different number of bits than specified + here, then you should use a different selector (dkimSelector). In order to get + this package to generate a key with the new number of bits, you will either have to + change the selector or delete the old key file. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.opendkim; + description = "OpenDKIM package to use."; + }; + }; + + config = mkIf (cfg.dkim.signing && cfg.enable) { + services.opendkim = { + enable = true; + selector = cfg.dkim.selector; + domains = "csl:${builtins.concatStringsSep "," cfg.local-domains}"; + configFile = pkgs.writeText "opendkim.conf" ('' + Canonicalization relaxed/simple + UMask 0002 + Socket ${dkim.socket} + KeyTable file:${keyTable} + SigningTable file:${signingTable} + '' + (lib.optionalString cfg.debug '' + Syslog yes + SyslogSuccess yes + LogWhy yes + '')); + }; + + users.users = { + "${config.services.postfix.user}" = { + extraGroups = [ "${config.services.opendkim.group}" ]; + }; + }; + + systemd.services.opendkim = { + preStart = lib.mkForce createAllCerts; + serviceConfig = { + ExecStart = lib.mkForce "${cfg.dkim.package}/bin/opendkim ${escapeShellArgs args}"; + PermissionsStartOnly = lib.mkForce false; + }; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.dkim.key-directory}' - ${config.services.opendkim.user} ${config.services.opendkim.group} - -" + ]; + }; +} diff --git a/lib/fudo/mail/dovecot.nix b/lib/fudo/mail/dovecot.nix new file mode 100644 index 0000000..e33050e --- /dev/null +++ b/lib/fudo/mail/dovecot.nix @@ -0,0 +1,314 @@ +{ config, lib, pkgs, environment, ... }: + +with lib; +let + cfg = config.fudo.mail-server; + + sieve-path = "${cfg.state-directory}/dovecot/imap_sieve"; + + pipe-bin = pkgs.stdenv.mkDerivation { + name = "pipe_bin"; + src = ./dovecot/pipe_bin; + buildInputs = with pkgs; [ makeWrapper coreutils bash rspamd ]; + buildCommand = '' + mkdir -p $out/pipe/bin + cp $src/* $out/pipe/bin/ + chmod a+x $out/pipe/bin/* + patchShebangs $out/pipe/bin + + for file in $out/pipe/bin/*; do + wrapProgram $file \ + --set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin" + done + ''; + }; + + ldap-conf-template = ldap-cfg: + let + ssl-config = if (ldap-cfg.ca == null) then '' + tls = no + tls_require_cert = try + '' else '' + tls_ca_cert_file = ${ldap-cfg.ca} + tls = yes + tls_require_cert = try + ''; + in + pkgs.writeText "dovecot2-ldap-config.conf.template" '' + uris = ${concatStringsSep " " ldap-cfg.server-urls} + ldap_version = 3 + dn = ${ldap-cfg.reader-dn} + dnpass = __LDAP_READER_PASSWORD__ + auth_bind = yes + auth_bind_userdn = uid=%u,ou=members,dc=fudo,dc=org + base = dc=fudo,dc=org + ${ssl-config} + ''; + + ldap-conf-generator = ldap-cfg: let + template = ldap-conf-template ldap-cfg; + target-dir = dirOf ldap-cfg.generated-ldap-config; + target = ldap-cfg.generated-ldap-config; + in pkgs.writeScript "dovecot2-ldap-password-swapper.sh" '' + mkdir -p ${target-dir} + touch ${target} + chmod 600 ${target} + chown ${config.services.dovecot2.user} ${target} + LDAP_READER_PASSWORD=$( cat "${ldap-cfg.reader-password-file}" ) + sed 's/__LDAP_READER_PASSWORD__/$LDAP_READER_PASSWORD/' '${template}' > ${target} + ''; + + ldap-passwd-entry = ldap-config: '' + passdb { + driver = ldap + args = ${ldap-conf "ldap-passdb.conf" ldap-config} + } + ''; + + ldapOpts = { + options = with types; { + ca = mkOption { + type = nullOr str; + description = "The path to the CA cert used to sign the LDAP server certificate."; + default = null; + }; + + base = mkOption { + type = str; + description = "Base of the LDAP server database."; + example = "dc=fudo,dc=org"; + }; + + server-urls = mkOption { + type = listOf str; + description = "A list of LDAP server URLs used for authentication."; + }; + + reader-dn = mkOption { + type = str; + description = '' + DN to use for reading user information. Needs access to homeDirectory, + uidNumber, gidNumber, and uid, but not password attributes. + ''; + }; + + reader-password-file = mkOption { + type = str; + description = "Password for the user specified in ldap-reader-dn."; + }; + + generated-ldap-config = mkOption { + type = str; + description = "Path at which to store the generated LDAP config file, including password."; + default = "/run/dovecot2/config/ldap.conf"; + }; + }; + }; + + dovecot-user = config.services.dovecot2.user; + +in { + options.fudo.mail-server.dovecot = with types; { + ssl-private-key = mkOption { + type = str; + description = "Location of the server SSL private key."; + }; + + ssl-certificate = mkOption { + type = str; + description = "Location of the server SSL certificate."; + }; + + ldap = mkOption { + type = nullOr (submodule ldapOpts); + default = null; + description = '' + LDAP auth server configuration. If omitted, the server will use local authentication. + ''; + }; + }; + + config = mkIf cfg.enable { + + services.prometheus.exporters.dovecot = mkIf cfg.monitoring { + enable = true; + scopes = ["user" "global"]; + listenAddress = "127.0.0.1"; + port = 9166; + socketPath = "/var/run/dovecot2/old-stats"; + }; + + services.dovecot2 = { + enable = true; + enableImap = true; + enableLmtp = true; + enablePop3 = true; + enablePAM = cfg.dovecot.ldap == null; + + createMailUser = true; + + mailUser = cfg.mail-user; + mailGroup = cfg.mail-group; + mailLocation = "maildir:${cfg.mail-directory}/%u/"; + + sslServerCert = cfg.dovecot.ssl-certificate; + sslServerKey = cfg.dovecot.ssl-private-key; + + modules = [ pkgs.dovecot_pigeonhole ]; + protocols = [ "sieve" ]; + + sieveScripts = { + after = builtins.toFile "spam.sieve" '' + require "fileinto"; + + if header :is "X-Spam" "Yes" { + fileinto "Junk"; + stop; + } + ''; + }; + + mailboxes = cfg.mailboxes; + + extraConfig = '' + #Extra Config + + ${optionalString cfg.monitoring '' + # The prometheus exporter still expects an older style of metrics + mail_plugins = $mail_plugins old_stats + service old-stats { + unix_listener old-stats { + user = dovecot-exporter + group = dovecot-exporter + } + } + ''} + + ${lib.optionalString cfg.debug '' + mail_debug = yes + auth_debug = yes + verbose_ssl = yes + ''} + + protocol imap { + mail_max_userip_connections = ${toString cfg.max-user-connections} + mail_plugins = $mail_plugins imap_sieve + } + + protocol pop3 { + mail_max_userip_connections = ${toString cfg.max-user-connections} + } + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + mail_access_groups = ${cfg.mail-group} + ssl = required + + # When looking up usernames, just use the name, not the full address + auth_username_format = %n + + service lmtp { + # Enable logging in debug mode + ${optionalString cfg.debug "executable = lmtp -L"} + + # Unix socket for postfix to deliver messages via lmtp + unix_listener dovecot-lmtp { + user = "postfix" + group = ${cfg.mail-group} + mode = 0600 + } + + # Drop privs, since all mail is owned by one user + # user = ${cfg.mail-user} + # group = ${cfg.mail-group} + user = root + } + + auth_mechanisms = login plain + + ${optionalString (cfg.dovecot.ldap != null) '' + passdb { + driver = ldap + args = ${cfg.dovecot.ldap.generated-ldap-config} + } + ''} + userdb { + driver = static + args = uid=${toString cfg.mail-user-id} home=${cfg.mail-directory}/%u + } + + # Used by postfix to authorize users + service auth { + unix_listener auth { + mode = 0660 + user = "${config.services.postfix.user}" + group = ${cfg.mail-group} + } + + unix_listener auth-userdb { + mode = 0660 + user = "${config.services.postfix.user}" + group = ${cfg.mail-group} + } + } + + service auth-worker { + user = root + } + + service imap { + vsz_limit = 1024M + } + + namespace inbox { + separator = "/" + inbox = yes + } + + plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + sieve = file:/var/sieve/%u/scripts;active=/var/sieve/%u/active.sieve + sieve_default = file:/var/sieve/%u/default.sieve + sieve_default_name = default + # From elsewhere to Spam folder + imapsieve_mailbox1_name = Junk + imapsieve_mailbox1_causes = COPY + imapsieve_mailbox1_before = file:${sieve-path}/report-spam.sieve + # From Spam folder to elsewhere + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = Junk + imapsieve_mailbox2_causes = COPY + imapsieve_mailbox2_before = file:${sieve-path}/report-ham.sieve + sieve_pipe_bin_dir = ${pipe-bin}/pipe/bin + sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment + } + + recipient_delimiter = + + + lmtp_save_to_detail_mailbox = yes + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + ''; + }; + + systemd = { + tmpfiles.rules = [ + "d ${sieve-path} 750 ${dovecot-user} ${cfg.mail-group} - -" + ]; + + services.dovecot2.preStart = '' + rm -f ${sieve-path}/* + cp -p ${./dovecot/imap_sieve}/*.sieve ${sieve-path} + for k in ${sieve-path}/*.sieve ; do + ${pkgs.dovecot_pigeonhole}/bin/sievec "$k" + done + + ${optionalString (cfg.dovecot.ldap != null) + (ldap-conf-generator cfg.dovecot.ldap)} + ''; + }; + }; +} diff --git a/lib/fudo/mail/dovecot/imap_sieve/report-ham.sieve b/lib/fudo/mail/dovecot/imap_sieve/report-ham.sieve new file mode 100644 index 0000000..a9d30cf --- /dev/null +++ b/lib/fudo/mail/dovecot/imap_sieve/report-ham.sieve @@ -0,0 +1,15 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +if string "${mailbox}" "Trash" { + stop; +} + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "sa-learn-ham.sh" [ "${username}" ]; diff --git a/lib/fudo/mail/dovecot/imap_sieve/report-spam.sieve b/lib/fudo/mail/dovecot/imap_sieve/report-spam.sieve new file mode 100644 index 0000000..4024b7a --- /dev/null +++ b/lib/fudo/mail/dovecot/imap_sieve/report-spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "sa-learn-spam.sh" [ "${username}" ]; \ No newline at end of file diff --git a/lib/fudo/mail/dovecot/pipe_bin/sa-learn-ham.sh b/lib/fudo/mail/dovecot/pipe_bin/sa-learn-ham.sh new file mode 100755 index 0000000..76fc4ed --- /dev/null +++ b/lib/fudo/mail/dovecot/pipe_bin/sa-learn-ham.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_ham \ No newline at end of file diff --git a/lib/fudo/mail/dovecot/pipe_bin/sa-learn-spam.sh b/lib/fudo/mail/dovecot/pipe_bin/sa-learn-spam.sh new file mode 100755 index 0000000..2a2f766 --- /dev/null +++ b/lib/fudo/mail/dovecot/pipe_bin/sa-learn-spam.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_spam \ No newline at end of file diff --git a/lib/fudo/mail/postfix.nix b/lib/fudo/mail/postfix.nix new file mode 100644 index 0000000..7525f4d --- /dev/null +++ b/lib/fudo/mail/postfix.nix @@ -0,0 +1,319 @@ +{ config, pkgs, lib, ... }: + +with lib; +let + inherit (lib.strings) concatStringsSep; + + cfg = config.fudo.mail-server; + + # The final newline is important + write-entries = filename: entries: + let + entries-string = (concatStringsSep "\n" entries); + in builtins.toFile filename '' + ${entries-string} + ''; + + make-user-aliases = entries: + concatStringsSep "\n" + (mapAttrsToList (user: aliases: + concatStringsSep "\n" + (map (alias: "${alias} ${user}") aliases)) + entries); + + make-alias-users = domains: entries: + concatStringsSep "\n" + (flatten + (mapAttrsToList (alias: users: + (map (domain: + "${alias}@${domain} ${concatStringsSep "," users}") + domains)) + entries)); + + policyd-spf = pkgs.writeText "policyd-spf.conf" ( + cfg.postfix.policy-spf-extra-config + + (lib.optionalString cfg.debug '' + debugLevel = 4 + '')); + + submission-header-cleanup-rules = pkgs.writeText "submission_header_cleanup_rules" ('' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. + + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + ''); + blacklist-postfix-entry = sender: "${sender} REJECT"; + blacklist-postfix-file = filename: entries: + write-entries filename entries; + sender-blacklist-file = blacklist-postfix-file "reject_senders" + (map blacklist-postfix-entry cfg.sender-blacklist); + recipient-blacklist-file = blacklist-postfix-file "reject_recipients" + (map blacklist-postfix-entry cfg.recipient-blacklist); + + # A list of domains for which we accept mail + virtual-mailbox-map-file = write-entries "virtual_mailbox_map" + (map (domain: "@${domain} OK") (cfg.local-domains ++ [cfg.domain])); + + sender-login-map-file = let + escapeDot = (str: replaceStrings ["."] ["\\."] str); + in write-entries "sender_login_maps" + (map (domain: "/^(.*)@${escapeDot domain}$/ \${1}") (cfg.local-domains ++ [cfg.domain])); + + mapped-file = name: "hash:/var/lib/postfix/conf/${name}"; + + pcre-file = name: "pcre:/var/lib/postfix/conf/${name}"; + +in { + + options.fudo.mail-server.postfix = { + + ssl-private-key = mkOption { + type = types.str; + description = "Location of the server SSL private key."; + }; + + ssl-certificate = mkOption { + type = types.str; + description = "Location of the server SSL certificate."; + }; + + policy-spf-extra-config = mkOption { + type = types.lines; + default = ""; + example = '' + skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1 + ''; + description = '' + Extra configuration options for policyd-spf. This can be use to among + other things skip spf checking for some IP addresses. + ''; + }; + }; + + config = mkIf cfg.enable { + + services.prometheus.exporters.postfix = mkIf cfg.monitoring { + enable = true; + systemd.enable = true; + showqPath = "/var/lib/postfix/queue/public/showq"; + user = config.services.postfix.user; + group = config.services.postfix.group; + }; + + services.postfix = { + enable = true; + domain = cfg.domain; + origin = cfg.domain; + hostname = cfg.mail-hostname; + destination = ["localhost" "localhost.localdomain"]; + # destination = ["localhost" "localhost.localdomain" cfg.hostname] ++ + # cfg.local-domains;; + + enableHeaderChecks = true; + enableSmtp = true; + enableSubmission = true; + + mapFiles."reject_senders" = sender-blacklist-file; + mapFiles."reject_recipients" = recipient-blacklist-file; + mapFiles."virtual_mailbox_map" = virtual-mailbox-map-file; + mapFiles."sender_login_map" = sender-login-map-file; + + # TODO: enable! + # headerChecks = [ { action = "REDIRECT spam@example.com"; pattern = "/^X-Spam-Flag:/"; } ]; + networks = cfg.trusted-networks; + + virtual = '' + ${make-user-aliases cfg.user-aliases} + + ${make-alias-users ([cfg.domain] ++ cfg.local-domains) cfg.alias-users} + ''; + + sslCert = cfg.postfix.ssl-certificate; + sslKey = cfg.postfix.ssl-private-key; + + config = { + virtual_mailbox_domains = cfg.local-domains ++ [cfg.domain]; + # virtual_mailbox_base = "${cfg.mail-directory}/"; + virtual_mailbox_maps = mapped-file "virtual_mailbox_map"; + + virtual_uid_maps = "static:${toString cfg.mail-user-id}"; + virtual_gid_maps = "static:${toString config.users.groups."${cfg.mail-group}".gid}"; + + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + + # NOTE: it's important that this ends with /, to indicate Maildir format! + # mail_spool_directory = "${cfg.mail-directory}/"; + message_size_limit = toString(cfg.message-size-limit * 1024 * 1024); + + smtpd_banner = "${cfg.mail-hostname} ESMTP NO UCE"; + + tls_eecdh_strong_curve = "prime256v1"; + tls_eecdh_ultra_curve = "secp384r1"; + + policy-spf_time_limit = "3600s"; + + smtp_host_lookup = "dns, native"; + + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_local_domain = "fudo.org"; + + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_tls_security_options = "noanonymous"; + + smtpd_sender_login_maps = (pcre-file "sender_login_map"); + + disable_vrfy_command = "yes"; + + recipient_delimiter = "+"; + + milter_protocol = "6"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + + smtpd_milters = [ + "unix:/run/rspamd/rspamd-milter.sock" + "unix:/var/run/opendkim/opendkim.sock" + ]; + + non_smtpd_milters = [ + "unix:/run/rspamd/rspamd-milter.sock" + "unix:/var/run/opendkim/opendkim.sock" + ]; + + smtpd_relay_restrictions = [ + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unauth_destination" + "reject_unauth_pipelining" + "reject_unauth_destination" + "reject_unknown_sender_domain" + ]; + + smtpd_sender_restrictions = [ + "check_sender_access ${mapped-file "reject_senders"}" + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unknown_sender_domain" + ]; + + smtpd_recipient_restrictions = [ + "check_sender_access ${mapped-file "reject_recipients"}" + "permit_mynetworks" + "permit_sasl_authenticated" + "check_policy_service unix:private/policy-spf" + "reject_unknown_recipient_domain" + "reject_unauth_pipelining" + "reject_unauth_destination" + "reject_invalid_hostname" + "reject_non_fqdn_hostname" + "reject_non_fqdn_sender" + "reject_non_fqdn_recipient" + ]; + + smtpd_helo_restrictions = [ + "permit_mynetworks" + "reject_invalid_hostname" + "permit" + ]; + + # Handled by submission + smtpd_tls_security_level = "may"; + + smtpd_tls_eecdh_grade = "ultra"; + + # Disable obselete protocols + smtpd_tls_protocols = [ + "TLSv1.2" + "TLSv1.1" + "!TLSv1" + "!SSLv2" + "!SSLv3" + ]; + smtp_tls_protocols = [ + "TLSv1.2" + "TLSv1.1" + "!TLSv1" + "!SSLv2" + "!SSLv3" + ]; + smtpd_tls_mandatory_protocols = [ + "TLSv1.2" + "TLSv1.1" + "!TLSv1" + "!SSLv2" + "!SSLv3" + ]; + smtp_tls_mandatory_protocols = [ + "TLSv1.2" + "TLSv1.1" + "!TLSv1" + "!SSLv2" + "!SSLv3" + ]; + + smtp_tls_ciphers = "high"; + smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; + smtpd_tls_mandatory_ciphers = "high"; + + smtpd_tls_mandatory_exclude_ciphers = ["MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL"]; + smtpd_tls_exclude_ciphers = ["MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL"]; + smtp_tls_mandatory_exclude_ciphers = ["MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL"]; + smtp_tls_exclude_ciphers = ["MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL"]; + + tls_preempt_cipherlist = "yes"; + + smtpd_tls_auth_only = "yes"; + + smtpd_tls_loglevel = "1"; + + tls_random_source = "dev:/dev/urandom"; + }; + + submissionOptions = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_local_domain = cfg.domain; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + smtpd_sender_restrictions = "reject_sender_login_mismatch,reject_unknown_sender_domain"; + smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; + + masterConfig = { + "policy-spf" = { + type = "unix"; + privileged = true; + chroot = false; + command = "spawn"; + args = [ "user=nobody" "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" "${policyd-spf}"]; + }; + "submission-header-cleanup" = { + type = "unix"; + private = false; + chroot = false; + maxproc = 0; + command = "cleanup"; + args = ["-o" "header_checks=pcre:${submission-header-cleanup-rules}"]; + }; + }; + }; + + # Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work + systemd.services.postfix = { + after = [ "dovecot2.service" ] + ++ (lib.optional cfg.dkim.signing "opendkim.service"); + requires = [ "dovecot2.service" ] + ++ (lib.optional cfg.dkim.signing "opendkim.service"); + }; + }; +} diff --git a/lib/fudo/mail/rspamd.nix b/lib/fudo/mail/rspamd.nix new file mode 100644 index 0000000..4bc9324 --- /dev/null +++ b/lib/fudo/mail/rspamd.nix @@ -0,0 +1,88 @@ +{ config, pkgs, lib, ... }: + +with lib; +let + cfg = config.fudo.mail-server; + +in { + config = mkIf cfg.enable { + services.prometheus.exporters.rspamd.enable = true; + + services.rspamd = { + + enable = true; + + locals = { + "milter_headers.conf" = { + text = '' + extended_spam_headers = yes; + ''; + }; + + "antivirus.conf" = { + text = '' + clamav { + action = "reject"; + symbol = "CLAM_VIRUS"; + type = "clamav"; + log_clean = true; + servers = "/run/clamav/clamd.ctl"; + scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all + } + ''; + }; + }; + + overrides = { + "milter_headers.conf" = { + text = '' + extended_spam_headers = true; + ''; + }; + }; + + workers.rspamd_proxy = { + type = "rspamd_proxy"; + bindSockets = [{ + socket = "/run/rspamd/rspamd-milter.sock"; + mode = "0664"; + }]; + count = 1; # Do not spawn too many processes of this type + extraConfig = '' + milter = yes; # Enable milter mode + timeout = 120s; # Needed for Milter usually + + upstream "local" { + default = yes; # Self-scan upstreams are always default + self_scan = yes; # Enable self-scan + } + ''; + }; + + workers.controller = { + type = "controller"; + count = 1; + bindSockets = [ + "localhost:11334" + { + socket = "/run/rspamd/worker-controller.sock"; + mode = "0666"; + } + ]; + includes = []; + }; + }; + + systemd.services.rspamd = { + requires = (optional cfg.clamav.enable "clamav-daemon.service"); + after = (optional cfg.clamav.enable "clamav-daemon.service"); + }; + + systemd.services.postfix = { + after = [ "rspamd.service" ]; + requires = [ "rspamd.service" ]; + }; + + users.extraUsers.${config.services.postfix.user}.extraGroups = [ config.services.rspamd.group ]; + }; +} diff --git a/lib/fudo/minecraft-server.nix b/lib/fudo/minecraft-server.nix new file mode 100644 index 0000000..9ee5474 --- /dev/null +++ b/lib/fudo/minecraft-server.nix @@ -0,0 +1,64 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.minecraft-server; + +in { + options.fudo.minecraft-server = { + enable = mkEnableOption "Start a minecraft server."; + + package = mkOption { + type = types.package; + description = "Minecraft package to use."; + default = pkgs.minecraft-server_1_15_1; + }; + + data-dir = mkOption { + type = types.path; + description = "Path at which to store minecraft data."; + }; + + world-name = mkOption { + type = types.str; + description = "Name of the server world (used in saves etc)."; + }; + + motd = mkOption { + type = types.str; + description = "Welcome message for newcomers."; + }; + + game-mode = mkOption { + type = types.enum ["survival" "creative" "adventure" "spectator"]; + description = "Game mode of the server."; + default = "survival"; + }; + + difficulty = mkOption { + type = types.int; + description = "Difficulty level, where 0 is peaceful and 3 is hard."; + default = 2; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ + cfg.package + ]; + + services.minecraft-server = { + enable = true; + package = cfg.package; + dataDir = cfg.data-dir; + eula = true; + declarative = true; + serverProperties = { + level-name = cfg.world-name; + motd = cfg.motd; + difficulty = cfg.difficulty; + gamemode = cfg.game-mode; + }; + }; + }; +} diff --git a/lib/fudo/netinfo-email.nix b/lib/fudo/netinfo-email.nix new file mode 100644 index 0000000..4dcc3a2 --- /dev/null +++ b/lib/fudo/netinfo-email.nix @@ -0,0 +1,93 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.netinfo-email; + + make-script = server: port: target: pkgs.writeText "netinfo-script.rb" '' + #!${pkgs.ruby}/bin/ruby + + require 'net/smtp' + + raise RuntimeError.new("NETINFO_SMTP_USERNAME not set!") if not ENV['NETINFO_SMTP_USERNAME'] + user = ENV['NETINFO_SMTP_USERNAME'] + + raise RuntimeError.new("NETINFO_SMTP_PASSWD not set!") if not ENV['NETINFO_SMTP_PASSWD'] + passwd = ENV['NETINFO_SMTP_PASSWD'] + + hostname = `${pkgs.inetutils}/bin/hostname -f`.strip + date = `${pkgs.coreutils}/bin/date +%Y-%m-%d`.strip + email_date = `${pkgs.coreutils}/bin/date` + ipinfo = `${pkgs.iproute}/bin/ip addr` + + message = < 0; + }; + + mkZoneFileName = name: if name == "." then "root" else name; + + # replaces include: directives for keys with fake keys for nsd-checkconf + injectFakeKeys = keys: concatStrings + (mapAttrsToList + (keyName: keyOptions: '' + fakeKey="$(${pkgs.bind}/bin/tsig-keygen -a ${escapeShellArgs [ keyOptions.algorithm keyName ]} | grep -oP "\s*secret \"\K.*(?=\";)")" + sed "s@^\s*include:\s*\"${stateDir}/private/${keyName}\"\$@secret: $fakeKey@" -i $out/nsd.conf + '') + keys); + + nsdEnv = pkgs.buildEnv { + name = "nsd-env"; + + paths = [ configFile ] + ++ mapAttrsToList (name: zone: writeZoneData name zone.data) zoneConfigs; + + postBuild = '' + echo "checking zone files" + cd $out/zones + for zoneFile in *; do + echo "|- checking zone '$out/zones/$zoneFile'" + ${nsdPkg}/sbin/nsd-checkzone "$zoneFile" "$zoneFile" || { + if grep -q \\\\\\$ "$zoneFile"; then + echo zone "$zoneFile" contains escaped dollar signs \\\$ + echo Escaping them is not needed any more. Please make sure \ + to unescape them where they prefix a variable name. + fi + exit 1 + } + done + echo "checking configuration file" + # Save original config file including key references... + cp $out/nsd.conf{,.orig} + # ...inject mock keys into config + ${injectFakeKeys cfg.keys} + # ...do the checkconf + ${nsdPkg}/sbin/nsd-checkconf $out/nsd.conf + # ... and restore original config file. + mv $out/nsd.conf{.orig,} + ''; + }; + + writeZoneData = name: text: pkgs.writeTextFile { + name = "nsd-zone-${mkZoneFileName name}"; + inherit text; + destination = "/zones/${mkZoneFileName name}"; + }; + + + # options are ordered alphanumerically by the nixos option name + configFile = pkgs.writeTextDir "nsd.conf" '' + server: + chroot: "${stateDir}" + username: ${username} + # The directory for zonefile: files. The daemon chdirs here. + zonesdir: "${stateDir}" + # the list of dynamically added zones. + database: "${stateDir}/var/nsd.db" + pidfile: "${pidFile}" + xfrdfile: "${stateDir}/var/xfrd.state" + xfrdir: "${stateDir}/tmp" + zonelistfile: "${stateDir}/var/zone.list" + # interfaces + ${forEach " ip-address: " cfg.interfaces} + ip-freebind: ${yesOrNo cfg.ipFreebind} + hide-version: ${yesOrNo cfg.hideVersion} + identity: "${cfg.identity}" + ip-transparent: ${yesOrNo cfg.ipTransparent} + do-ip4: ${yesOrNo cfg.ipv4} + ipv4-edns-size: ${toString cfg.ipv4EDNSSize} + do-ip6: ${yesOrNo cfg.ipv6} + ipv6-edns-size: ${toString cfg.ipv6EDNSSize} + log-time-ascii: ${yesOrNo cfg.logTimeAscii} + ${maybeString "nsid: " cfg.nsid} + port: ${toString cfg.port} + reuseport: ${yesOrNo cfg.reuseport} + round-robin: ${yesOrNo cfg.roundRobin} + server-count: ${toString cfg.serverCount} + ${maybeToString "statistics: " cfg.statistics} + tcp-count: ${toString cfg.tcpCount} + tcp-query-count: ${toString cfg.tcpQueryCount} + tcp-timeout: ${toString cfg.tcpTimeout} + verbosity: ${toString cfg.verbosity} + ${maybeString "version: " cfg.version} + xfrd-reload-timeout: ${toString cfg.xfrdReloadTimeout} + zonefiles-check: ${yesOrNo cfg.zonefilesCheck} + ${maybeString "rrl-ipv4-prefix-length: " cfg.ratelimit.ipv4PrefixLength} + ${maybeString "rrl-ipv6-prefix-length: " cfg.ratelimit.ipv6PrefixLength} + rrl-ratelimit: ${toString cfg.ratelimit.ratelimit} + ${maybeString "rrl-slip: " cfg.ratelimit.slip} + rrl-size: ${toString cfg.ratelimit.size} + rrl-whitelist-ratelimit: ${toString cfg.ratelimit.whitelistRatelimit} + ${keyConfigFile} + remote-control: + control-enable: ${yesOrNo cfg.remoteControl.enable} + control-key-file: "${cfg.remoteControl.controlKeyFile}" + control-cert-file: "${cfg.remoteControl.controlCertFile}" + ${forEach " control-interface: " cfg.remoteControl.interfaces} + control-port: ${toString cfg.remoteControl.port} + server-key-file: "${cfg.remoteControl.serverKeyFile}" + server-cert-file: "${cfg.remoteControl.serverCertFile}" + ${concatStrings (mapAttrsToList zoneConfigFile zoneConfigs)} + ${cfg.extraConfig} + ''; + + yesOrNo = b: if b then "yes" else "no"; + maybeString = prefix: x: if x == null then "" else ''${prefix} "${x}"''; + maybeToString = prefix: x: if x == null then "" else ''${prefix} ${toString x}''; + forEach = pre: l: concatMapStrings (x: pre + x + "\n") l; + + + keyConfigFile = concatStrings (mapAttrsToList (keyName: keyOptions: '' + key: + name: "${keyName}" + algorithm: "${keyOptions.algorithm}" + include: "${stateDir}/private/${keyName}" + '') cfg.keys); + + copyKeys = concatStrings (mapAttrsToList (keyName: keyOptions: '' + secret=$(cat "${keyOptions.keyFile}") + dest="${stateDir}/private/${keyName}" + echo " secret: \"$secret\"" > "$dest" + chown ${username}:${username} "$dest" + chmod 0400 "$dest" + '') cfg.keys); + + + # options are ordered alphanumerically by the nixos option name + zoneConfigFile = name: zone: '' + zone: + name: "${name}" + zonefile: "${stateDir}/zones/${mkZoneFileName name}" + ${maybeString "outgoing-interface: " zone.outgoingInterface} + ${forEach " rrl-whitelist: " zone.rrlWhitelist} + ${maybeString "zonestats: " zone.zoneStats} + ${maybeToString "max-refresh-time: " zone.maxRefreshSecs} + ${maybeToString "min-refresh-time: " zone.minRefreshSecs} + ${maybeToString "max-retry-time: " zone.maxRetrySecs} + ${maybeToString "min-retry-time: " zone.minRetrySecs} + allow-axfr-fallback: ${yesOrNo zone.allowAXFRFallback} + ${forEach " allow-notify: " zone.allowNotify} + ${forEach " request-xfr: " zone.requestXFR} + ${forEach " notify: " zone.notify} + notify-retry: ${toString zone.notifyRetry} + ${forEach " provide-xfr: " zone.provideXFR} + ''; + + zoneConfigs = zoneConfigs' {} "" { children = cfg.zones; }; + + zoneConfigs' = parent: name: zone: + if !(zone ? children) || zone.children == null || zone.children == { } + # leaf -> actual zone + then listToAttrs [ (nameValuePair name (parent // zone)) ] + + # fork -> pattern + else zipAttrsWith (name: head) ( + mapAttrsToList (name: child: zoneConfigs' (parent // zone // { children = {}; }) name child) + zone.children + ); + + # fighting infinite recursion + zoneOptions = zoneOptionsRaw // childConfig zoneOptions1 true; + zoneOptions1 = zoneOptionsRaw // childConfig zoneOptions2 false; + zoneOptions2 = zoneOptionsRaw // childConfig zoneOptions3 false; + zoneOptions3 = zoneOptionsRaw // childConfig zoneOptions4 false; + zoneOptions4 = zoneOptionsRaw // childConfig zoneOptions5 false; + zoneOptions5 = zoneOptionsRaw // childConfig zoneOptions6 false; + zoneOptions6 = zoneOptionsRaw // childConfig null false; + + childConfig = x: v: { options.children = { type = types.attrsOf x; visible = v; }; }; + + # options are ordered alphanumerically + zoneOptionsRaw = types.submodule { + options = { + + allowAXFRFallback = mkOption { + type = types.bool; + default = true; + description = '' + If NSD as secondary server should be allowed to AXFR if the primary + server does not allow IXFR. + ''; + }; + + allowNotify = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "192.0.2.0/24 NOKEY" "10.0.0.1-10.0.0.5 my_tsig_key_name" + "10.0.3.4&255.255.0.0 BLOCKED" + ]; + description = '' + Listed primary servers are allowed to notify this secondary server. + + either a plain IPv4/IPv6 address or range. Valid patters for ranges: + * 10.0.0.0/24 # via subnet size + * 10.0.0.0&255.255.255.0 # via subnet mask + * 10.0.0.1-10.0.0.254 # via range + A optional port number could be added with a '@': + * 2001:1234::1@1234 + + * will use the specified TSIG key + * NOKEY no TSIG signature is required + * BLOCKED notifies from non-listed or blocked IPs will be ignored + * ]]> + ''; + }; + + children = mkOption { + default = {}; + description = '' + Children zones inherit all options of their parents. Attributes + defined in a child will overwrite the ones of its parent. Only + leaf zones will be actually served. This way it's possible to + define maybe zones which share most attributes without + duplicating everything. This mechanism replaces nsd's patterns + in a save and functional way. + ''; + }; + + data = mkOption { + type = types.lines; + default = ""; + example = ""; + description = '' + The actual zone data. This is the content of your zone file. + Use imports or pkgs.lib.readFile if you don't want this data in your config file. + ''; + }; + + dnssec = mkEnableOption "DNSSEC"; + + dnssecPolicy = { + algorithm = mkOption { + type = types.str; + default = "RSASHA256"; + description = "Which algorithm to use for DNSSEC"; + }; + keyttl = mkOption { + type = types.str; + default = "1h"; + description = "TTL for dnssec records"; + }; + coverage = mkOption { + type = types.str; + default = "1y"; + description = '' + The length of time to ensure that keys will be correct; no action will be taken to create new keys to be activated after this time. + ''; + }; + zsk = mkOption { + type = keyPolicy; + default = { keySize = 2048; + prePublish = "1w"; + postPublish = "1w"; + rollPeriod = "1mo"; + }; + description = "Key policy for zone signing keys"; + }; + ksk = mkOption { + type = keyPolicy; + default = { keySize = 4096; + prePublish = "1mo"; + postPublish = "1mo"; + rollPeriod = "0"; + }; + description = "Key policy for key signing keys"; + }; + }; + + maxRefreshSecs = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Limit refresh time for secondary zones. This is the timer which + checks to see if the zone has to be refetched when it expires. + Normally the value from the SOA record is used, but this option + restricts that value. + ''; + }; + + minRefreshSecs = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Limit refresh time for secondary zones. + ''; + }; + + maxRetrySecs = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Limit retry time for secondary zones. This is the timeout after + a failed fetch attempt for the zone. Normally the value from + the SOA record is used, but this option restricts that value. + ''; + }; + + minRetrySecs = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Limit retry time for secondary zones. + ''; + }; + + + notify = mkOption { + type = types.listOf types.str; + default = []; + example = [ "10.0.0.1@3721 my_key" "::5 NOKEY" ]; + description = '' + This primary server will notify all given secondary servers about + zone changes. + + a plain IPv4/IPv6 address with on optional port number (ip@port) + + * sign notifies with the specified key + * NOKEY don't sign notifies + ]]> + ''; + }; + + notifyRetry = mkOption { + type = types.int; + default = 5; + description = '' + Specifies the number of retries for failed notifies. Set this along with notify. + ''; + }; + + outgoingInterface = mkOption { + type = types.nullOr types.str; + default = null; + example = "2000::1@1234"; + description = '' + This address will be used for zone-transfere requests if configured + as a secondary server or notifications in case of a primary server. + Supply either a plain IPv4 or IPv6 address with an optional port + number (ip@port). + ''; + }; + + provideXFR = mkOption { + type = types.listOf types.str; + default = []; + example = [ "192.0.2.0/24 NOKEY" "192.0.2.0/24 my_tsig_key_name" ]; + description = '' + Allow these IPs and TSIG to transfer zones, addr TSIG|NOKEY|BLOCKED + address range 192.0.2.0/24, 1.2.3.4&255.255.0.0, 3.0.2.20-3.0.2.40 + ''; + }; + + requestXFR = mkOption { + type = types.listOf types.str; + default = []; + example = []; + description = '' + Format: [AXFR|UDP] <ip-address> <key-name | NOKEY> + ''; + }; + + rrlWhitelist = mkOption { + type = with types; listOf (enum [ "nxdomain" "error" "referral" "any" "rrsig" "wildcard" "nodata" "dnskey" "positive" "all" ]); + default = []; + description = '' + Whitelists the given rrl-types. + ''; + }; + + zoneStats = mkOption { + type = types.nullOr types.str; + default = null; + example = "%s"; + description = '' + When set to something distinct to null NSD is able to collect + statistics per zone. All statistics of this zone(s) will be added + to the group specified by this given name. Use "%s" to use the zones + name as the group. The groups are output from nsd-control stats + and stats_noreset. + ''; + }; + }; + }; + + keyPolicy = types.submodule { + options = { + keySize = mkOption { + type = types.int; + description = "Key size in bits"; + }; + prePublish = mkOption { + type = types.str; + description = "How long in advance to publish new keys"; + }; + postPublish = mkOption { + type = types.str; + description = "How long after deactivation to keep a key in the zone"; + }; + rollPeriod = mkOption { + type = types.str; + description = "How frequently to change keys"; + }; + }; + }; + + dnssecZones = (filterAttrs (n: v: if v ? dnssec then v.dnssec else false) zoneConfigs); + + dnssec = dnssecZones != {}; + + dnssecTools = pkgs.bind.override { enablePython = true; }; + + signZones = optionalString dnssec '' + mkdir -p ${stateDir}/dnssec + chown ${username}:${username} ${stateDir}/dnssec + chmod 0600 ${stateDir}/dnssec + ${concatStrings (mapAttrsToList signZone dnssecZones)} + ''; + signZone = name: zone: '' + ${dnssecTools}/bin/dnssec-keymgr -g ${dnssecTools}/bin/dnssec-keygen -s ${dnssecTools}/bin/dnssec-settime -K ${stateDir}/dnssec -c ${policyFile name zone.dnssecPolicy} ${name} + ${dnssecTools}/bin/dnssec-signzone -S -K ${stateDir}/dnssec -o ${name} -O full -N date ${stateDir}/zones/${name} + ${nsdPkg}/sbin/nsd-checkzone ${name} ${stateDir}/zones/${name}.signed && mv -v ${stateDir}/zones/${name}.signed ${stateDir}/zones/${name} + ''; + policyFile = name: policy: pkgs.writeText "${name}.policy" '' + zone ${name} { + algorithm ${policy.algorithm}; + key-size zsk ${toString policy.zsk.keySize}; + key-size ksk ${toString policy.ksk.keySize}; + keyttl ${policy.keyttl}; + pre-publish zsk ${policy.zsk.prePublish}; + pre-publish ksk ${policy.ksk.prePublish}; + post-publish zsk ${policy.zsk.postPublish}; + post-publish ksk ${policy.ksk.postPublish}; + roll-period zsk ${policy.zsk.rollPeriod}; + roll-period ksk ${policy.ksk.rollPeriod}; + coverage ${policy.coverage}; + }; + ''; +in +{ + # options are ordered alphanumerically + options.fudo.nsd = { + + enable = mkEnableOption "NSD authoritative DNS server"; + + bind8Stats = mkEnableOption "BIND8 like statistics"; + + dnssecInterval = mkOption { + type = types.str; + default = "1h"; + description = '' + How often to check whether dnssec key rollover is required + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Extra nsd config. + ''; + }; + + hideVersion = mkOption { + type = types.bool; + default = true; + description = '' + Whether NSD should answer VERSION.BIND and VERSION.SERVER CHAOS class queries. + ''; + }; + + identity = mkOption { + type = types.str; + default = "unidentified server"; + description = '' + Identify the server (CH TXT ID.SERVER entry). + ''; + }; + + interfaces = mkOption { + type = types.listOf types.str; + default = [ "127.0.0.0" "::1" ]; + description = '' + What addresses the server should listen to. + ''; + }; + + ipFreebind = mkOption { + type = types.bool; + default = false; + description = '' + Whether to bind to nonlocal addresses and interfaces that are down. + Similar to ip-transparent. + ''; + }; + + ipTransparent = mkOption { + type = types.bool; + default = false; + description = '' + Allow binding to non local addresses. + ''; + }; + + ipv4 = mkOption { + type = types.bool; + default = true; + description = '' + Whether to listen on IPv4 connections. + ''; + }; + + ipv4EDNSSize = mkOption { + type = types.int; + default = 4096; + description = '' + Preferred EDNS buffer size for IPv4. + ''; + }; + + ipv6 = mkOption { + type = types.bool; + default = true; + description = '' + Whether to listen on IPv6 connections. + ''; + }; + + ipv6EDNSSize = mkOption { + type = types.int; + default = 4096; + description = '' + Preferred EDNS buffer size for IPv6. + ''; + }; + + logTimeAscii = mkOption { + type = types.bool; + default = true; + description = '' + Log time in ascii, if false then in unix epoch seconds. + ''; + }; + + nsid = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + NSID identity (hex string, or "ascii_somestring"). + ''; + }; + + port = mkOption { + type = types.int; + default = 53; + description = '' + Port the service should bind do. + ''; + }; + + reuseport = mkOption { + type = types.bool; + default = pkgs.stdenv.isLinux; + description = '' + Whether to enable SO_REUSEPORT on all used sockets. This lets multiple + processes bind to the same port. This speeds up operation especially + if the server count is greater than one and makes fast restarts less + prone to fail + ''; + }; + + rootServer = mkOption { + type = types.bool; + default = false; + description = '' + Whether this server will be a root server (a DNS root server, you + usually don't want that). + ''; + }; + + roundRobin = mkEnableOption "round robin rotation of records"; + + serverCount = mkOption { + type = types.int; + default = 1; + description = '' + Number of NSD servers to fork. Put the number of CPUs to use here. + ''; + }; + + + stateDir = mkOption { + type = types.str; + description = "Directory at which to store NSD state data."; + default = "/var/lib/nsd"; + }; + + statistics = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Statistics are produced every number of seconds. Prints to log. + If null no statistics are logged. + ''; + }; + + tcpCount = mkOption { + type = types.int; + default = 100; + description = '' + Maximum number of concurrent TCP connections per server. + ''; + }; + + tcpQueryCount = mkOption { + type = types.int; + default = 0; + description = '' + Maximum number of queries served on a single TCP connection. + 0 means no maximum. + ''; + }; + + tcpTimeout = mkOption { + type = types.int; + default = 120; + description = '' + TCP timeout in seconds. + ''; + }; + + verbosity = mkOption { + type = types.int; + default = 0; + description = '' + Verbosity level. + ''; + }; + + version = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The version string replied for CH TXT version.server and version.bind + queries. Will use the compiled package version on null. + See hideVersion for enabling/disabling this responses. + ''; + }; + + xfrdReloadTimeout = mkOption { + type = types.int; + default = 1; + description = '' + Number of seconds between reloads triggered by xfrd. + ''; + }; + + zonefilesCheck = mkOption { + type = types.bool; + default = true; + description = '' + Whether to check mtime of all zone files on start and sighup. + ''; + }; + + + keys = mkOption { + type = types.attrsOf (types.submodule { + options = { + + algorithm = mkOption { + type = types.str; + default = "hmac-sha256"; + description = '' + Authentication algorithm for this key. + ''; + }; + + keyFile = mkOption { + type = types.path; + description = '' + Path to the file which contains the actual base64 encoded + key. The key will be copied into "${stateDir}/private" before + NSD starts. The copied file is only accessibly by the NSD + user. + ''; + }; + + }; + }); + default = {}; + example = literalExample '' + { "tsig.example.org" = { + algorithm = "hmac-md5"; + keyFile = "/path/to/my/key"; + }; + } + ''; + description = '' + Define your TSIG keys here. + ''; + }; + + + ratelimit = { + + enable = mkEnableOption "ratelimit capabilities"; + + ipv4PrefixLength = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + IPv4 prefix length. Addresses are grouped by netblock. + ''; + }; + + ipv6PrefixLength = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + IPv6 prefix length. Addresses are grouped by netblock. + ''; + }; + + ratelimit = mkOption { + type = types.int; + default = 200; + description = '' + Max qps allowed from any query source. + 0 means unlimited. With an verbosity of 2 blocked and + unblocked subnets will be logged. + ''; + }; + + slip = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Number of packets that get discarded before replying a SLIP response. + 0 disables SLIP responses. 1 will make every response a SLIP response. + ''; + }; + + size = mkOption { + type = types.int; + default = 1000000; + description = '' + Size of the hashtable. More buckets use more memory but lower + the chance of hash hash collisions. + ''; + }; + + whitelistRatelimit = mkOption { + type = types.int; + default = 2000; + description = '' + Max qps allowed from whitelisted sources. + 0 means unlimited. Set the rrl-whitelist option for specific + queries to apply this limit instead of the default to them. + ''; + }; + + }; + + + remoteControl = { + + enable = mkEnableOption "remote control via nsd-control"; + + controlCertFile = mkOption { + type = types.path; + default = "/etc/nsd/nsd_control.pem"; + description = '' + Path to the client certificate signed with the server certificate. + This file is used by nsd-control and generated by nsd-control-setup. + ''; + }; + + controlKeyFile = mkOption { + type = types.path; + default = "/etc/nsd/nsd_control.key"; + description = '' + Path to the client private key, which is used by nsd-control + but not by the server. This file is generated by nsd-control-setup. + ''; + }; + + interfaces = mkOption { + type = types.listOf types.str; + default = [ "127.0.0.1" "::1" ]; + description = '' + Which interfaces NSD should bind to for remote control. + ''; + }; + + port = mkOption { + type = types.int; + default = 8952; + description = '' + Port number for remote control operations (uses TLS over TCP). + ''; + }; + + serverCertFile = mkOption { + type = types.path; + default = "/etc/nsd/nsd_server.pem"; + description = '' + Path to the server self signed certificate, which is used by the server + but and by nsd-control. This file is generated by nsd-control-setup. + ''; + }; + + serverKeyFile = mkOption { + type = types.path; + default = "/etc/nsd/nsd_server.key"; + description = '' + Path to the server private key, which is used by the server + but not by nsd-control. This file is generated by nsd-control-setup. + ''; + }; + + }; + + zones = mkOption { + type = types.attrsOf zoneOptions; + default = {}; + example = literalExample '' + { "serverGroup1" = { + provideXFR = [ "10.1.2.3 NOKEY" ]; + children = { + "example.com." = { + data = ''' + $ORIGIN example.com. + $TTL 86400 + @ IN SOA a.ns.example.com. admin.example.com. ( + ... + '''; + }; + "example.org." = { + data = ''' + $ORIGIN example.org. + $TTL 86400 + @ IN SOA a.ns.example.com. admin.example.com. ( + ... + '''; + }; + }; + }; + "example.net." = { + provideXFR = [ "10.3.2.1 NOKEY" ]; + data = ''' + ... + '''; + }; + } + ''; + description = '' + Define your zones here. Zones can cascade other zones and therefore + inherit settings from parent zones. Look at the definition of + children to learn about inheritance and child zones. + The given example will define 3 zones (example.(com|org|net).). Both + example.com. and example.org. inherit their configuration from + serverGroup1. + ''; + }; + }; + + config = mkIf cfg.enable { + + assertions = singleton { + assertion = zoneConfigs ? "." -> cfg.rootServer; + message = "You have a root zone configured. If this is really what you " + + "want, please enable 'services.nsd.rootServer'."; + }; + + environment = { + systemPackages = [ nsdPkg ]; + etc."nsd/nsd.conf".source = "${configFile}/nsd.conf"; + }; + + users.groups.${username}.gid = config.ids.gids.nsd; + + users.users.${username} = { + description = "NSD service user"; + home = stateDir; + createHome = true; + uid = config.ids.uids.nsd; + group = username; + }; + + systemd.services.nsd = { + description = "NSD authoritative only domain name service"; + + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + startLimitBurst = 4; + startLimitIntervalSec = 5 * 60; # 5 mins + serviceConfig = { + ExecStart = "${nsdPkg}/sbin/nsd -d -c ${nsdEnv}/nsd.conf"; + StandardError = "null"; + PIDFile = pidFile; + Restart = "always"; + RestartSec = "4s"; + }; + + preStart = '' + rm -Rf "${stateDir}/private/" + rm -Rf "${stateDir}/tmp/" + mkdir -m 0700 -p "${stateDir}/private" + mkdir -m 0700 -p "${stateDir}/tmp" + mkdir -m 0700 -p "${stateDir}/var" + cat > "${stateDir}/don't touch anything in here" << EOF + Everything in this directory except NSD's state in var and dnssec + is automatically generated and will be purged and redeployed by + the nsd.service pre-start script. + EOF + chown ${username}:${username} -R "${stateDir}/private" + chown ${username}:${username} -R "${stateDir}/tmp" + chown ${username}:${username} -R "${stateDir}/var" + rm -rf "${stateDir}/zones" + cp -rL "${nsdEnv}/zones" "${stateDir}/zones" + ${copyKeys} + ''; + }; + + systemd.timers.nsd-dnssec = mkIf dnssec { + description = "Automatic DNSSEC key rollover"; + + wantedBy = [ "nsd.service" ]; + + timerConfig = { + OnActiveSec = cfg.dnssecInterval; + OnUnitActiveSec = cfg.dnssecInterval; + }; + }; + + systemd.services.nsd-dnssec = mkIf dnssec { + description = "DNSSEC key rollover"; + + wantedBy = [ "nsd.service" ]; + before = [ "nsd.service" ]; + + script = signZones; + + postStop = '' + /run/current-system/systemd/bin/systemctl kill -s SIGHUP nsd.service + ''; + }; + + }; +} diff --git a/lib/fudo/password.nix b/lib/fudo/password.nix new file mode 100644 index 0000000..2c7d0e4 --- /dev/null +++ b/lib/fudo/password.nix @@ -0,0 +1,116 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.password; + + genOpts = { + options = { + file = mkOption { + type = types.str; + description = "Password file in which to store a generated password."; + }; + + user = mkOption { + type = types.str; + description = "User to which the file should belong."; + }; + + group = mkOption { + type = with types; nullOr str; + description = "Group to which the file should belong."; + default = "nogroup"; + }; + + restart-services = mkOption { + type = with types; listOf str; + description = "List of services to restart when the password file is generated."; + default = []; + }; + }; + }; + + generate-passwd-file = file: user: group: pkgs.writeShellScriptBin "generate-passwd-file.sh" '' + mkdir -p $(dirname ${file}) + + if touch ${file}; then + chown ${user}${optionalString (group != null) ":${group}"} ${file} + if [ $? -ne 0 ]; then + rm ${file} + echo "failed to set permissions on ${file}" + exit 4 + fi + ${pkgs.pwgen}/bin/pwgen 30 1 > ${file} + else + echo "cannot write to ${file}" + exit 2 + fi + + if [ ! -f ${file} ]; then + echo "Failed to create file ${file}" + exit 3 + fi + + ${if (group != null) then + "chmod 640 ${file}" + else + "chmod 600 ${file}"} + + echo "created password file ${file}" + exit 0 + ''; + + restart-script = service-name: '' + SYSCTL=${pkgs.systemd}/bin/systemctl + JOBTYPE=$(${pkgs.systemd}/bin/systemctl show ${service-name} -p Type) + if $SYSCTL is-active --quiet ${service-name} || + [ $JOBTYPE == "Type=simple" ] || + [ $JOBTYPE == "Type=oneshot" ] ; then + echo "restarting service ${service-name} because password has changed." + $SYSCTL restart ${service-name} + fi + ''; + + filterForRestarts = filterAttrs (name: opts: opts.restart-services != []); + +in { + options.fudo.password = { + file-generator = mkOption { + type = with types; attrsOf (submodule genOpts); + description = "List of password files to generate."; + default = {}; + }; + }; + + config = { + systemd.targets.fudo-passwords = { + description = "Target indicating that all Fudo passwords have been generated."; + wantedBy = [ "default.target" ]; + }; + + systemd.services = fold (a: b: a // b) {} (mapAttrsToList (name: opts: { + "file-generator-${name}" = { + enable = true; + partOf = [ "fudo-passwords.target" ]; + serviceConfig.Type = "oneshot"; + description = "Generate password file for ${name}."; + script = "${generate-passwd-file opts.file opts.user opts.group}/bin/generate-passwd-file.sh"; + reloadIfChanged = true; + }; + + "file-generator-watcher-${name}" = mkIf (! (opts.restart-services == [])) { + description = "Restart services upon regenerating password for ${name}"; + after = [ "file-generator-${name}.service" ]; + partOf = [ "fudo-passwords.target" ]; + serviceConfig.Type = "oneshot"; + script = concatStringsSep "\n" (map restart-script opts.restart-services); + }; + }) cfg.file-generator); + + systemd.paths = mapAttrs' (name: opts: + nameValuePair "file-generator-watcher-${name}" { + partOf = [ "fudo-passwords.target"]; + pathConfig.PathChanged = opts.file; + }) (filterForRestarts cfg.file-generator); + }; +} diff --git a/lib/fudo/postgres.nix b/lib/fudo/postgres.nix new file mode 100644 index 0000000..8ab72cc --- /dev/null +++ b/lib/fudo/postgres.nix @@ -0,0 +1,370 @@ +{ config, lib, pkgs, environment, ... }: + +with lib; +let + cfg = config.fudo.postgresql; + + hostname = config.instance.hostname; + domain-name = config.instance.local-domain; + + gssapi-realm = config.fudo.domains.${domain-name}.gssapi-realm; + + join-lines = lib.concatStringsSep "\n"; + + strip-ext = filename: + head (builtins.match "^(.+)[.][^.]+$" filename); + + userDatabaseOpts = { database, ... }: { + options = { + access = mkOption { + type = types.str; + description = "Privileges for user on this database."; + default = "CONNECT"; + }; + + entity-access = mkOption { + type = with types; attrsOf str; + description = + "A list of entities mapped to the access this user should have."; + default = { }; + example = { + "TABLE users" = "SELECT,DELETE"; + "ALL SEQUENCES IN public" = "SELECT"; + }; + }; + }; + }; + + userOpts = { username, ... }: { + options = with types; { + password-file = mkOption { + type = nullOr str; + description = "A file containing the user's (plaintext) password."; + default = null; + }; + + databases = mkOption { + type = attrsOf (submodule userDatabaseOpts); + description = "Map of databases to required database/table perms."; + default = { }; + example = { + my_database = { + access = "ALL PRIVILEGES"; + entity-access = { "ALL TABLES" = "SELECT"; }; + }; + }; + }; + }; + }; + + databaseOpts = { dbname, ... }: { + options = with types; { + users = mkOption { + type = listOf str; + description = + "A list of users who should have full access to this database."; + default = [ ]; + }; + }; + }; + + filterPasswordedUsers = filterAttrs (user: opts: opts.password-file != null); + + password-setter-script = user: password-file: sql-file: '' + unset PASSWORD + if [ ! -f ${password-file} ]; then + echo "file does not exist: ${password-file}" + exit 1 + fi + PASSWORD=$(cat ${password-file}) + echo "setting password for user ${user}" + echo "ALTER USER ${user} ENCRYPTED PASSWORD '$PASSWORD';" >> ${sql-file} + ''; + + passwords-setter-script = users: + pkgs.writeScript "postgres-set-passwords.sh" '' + if [ $# -ne 1 ]; then + echo "usage: $0 output-file.sql" + exit 1 + fi + + OUTPUT_FILE=$1 + + if [ ! -f $OUTPUT_FILE ]; then + echo "file doesn't exist: $OUTPUT_FILE" + exit 2 + fi + + ${join-lines (mapAttrsToList (user: opts: + password-setter-script user opts.password-file "$OUTPUT_FILE") + (filterPasswordedUsers users))} + ''; + + userDatabaseAccess = user: databases: + mapAttrs' (database: databaseOpts: + nameValuePair "DATABASE ${database}" databaseOpts.access) databases; + + makeEntry = nw: + "host all all ${nw} gss include_realm=0 krb_realm=${gssapi-realm}"; + + makeNetworksEntry = networks: join-lines (map makeEntry networks); + + makeLocalUserPasswordEntries = users: + join-lines (mapAttrsToList (user: opts: + join-lines (map (db: '' + local ${db} ${user} md5 + host ${db} ${user} 127.0.0.1/16 md5 + host ${db} ${user} ::1/128 md5 + '') (attrNames opts.databases))) (filterPasswordedUsers users)); + + userTableAccessSql = user: entity: access: + "GRANT ${access} ON ${entity} TO ${user};"; + userDatabaseAccessSql = user: database: dbOpts: '' + \c ${database} + ${join-lines + (mapAttrsToList (userTableAccessSql user) dbOpts.entity-access)} + ''; + userAccessSql = user: userOpts: + join-lines (mapAttrsToList (userDatabaseAccessSql user) userOpts.databases); + usersAccessSql = users: join-lines (mapAttrsToList userAccessSql users); + +in { + + options.fudo.postgresql = with types; { + enable = mkEnableOption "Fudo PostgreSQL Server"; + + ssl-private-key = mkOption { + type = str; + description = "Location of the server SSL private key."; + }; + + ssl-certificate = mkOption { + type = str; + description = "Location of the server SSL certificate."; + }; + + keytab = mkOption { + type = str; + description = "Location of the server Kerberos keytab."; + }; + + local-networks = mkOption { + type = listOf str; + description = "A list of networks from which to accept connections."; + example = [ "10.0.0.1/16" ]; + default = [ ]; + }; + + users = mkOption { + type = attrsOf (submodule userOpts); + description = "A map of users to user attributes."; + example = { + sampleUser = { + password-file = "/path/to/password/file"; + databases = { + some_database = { + access = "CONNECT"; + entity-access = { "TABLE some_table" = "SELECT,UPDATE"; }; + }; + }; + }; + }; + default = { }; + }; + + databases = mkOption { + type = attrsOf (submodule databaseOpts); + description = "A map of databases to database options."; + default = { }; + }; + + socket-directory = mkOption { + type = str; + description = "Directory in which to place unix sockets."; + default = "/run/postgresql"; + }; + + socket-group = mkOption { + type = str; + description = "Group for accessing sockets."; + default = "postgres_local"; + }; + + local-users = mkOption { + type = listOf str; + description = "Users able to access the server via local socket."; + default = [ ]; + }; + + required-services = mkOption { + type = listOf str; + description = "List of services that should run before postgresql."; + default = [ ]; + example = [ "password-generator.service" ]; + }; + + state-directory = mkOption { + type = nullOr str; + description = "Path at which to store database state data."; + default = null; + }; + + cleanup-tasks = mkOption { + type = listOf str; + description = "List of actions to take during shutdown of the service."; + default = []; + }; + + systemd-target = mkOption { + type = str; + description = "Name of the systemd target for postgresql"; + default = "postgresql.target"; + }; + }; + + config = mkIf cfg.enable { + + environment = { + systemPackages = with pkgs; [ postgresql_11_gssapi ]; + + # etc = { + # "postgresql/private/privkey.pem" = { + # mode = "0400"; + # user = "postgres"; + # group = "postgres"; + # source = cfg.ssl-private-key; + # }; + + # "postgresql/cert.pem" = { + # mode = "0444"; + # user = "postgres"; + # group = "postgres"; + # source = cfg.ssl-certificate; + # }; + + # "postgresql/private/postgres.keytab" = { + # mode = "0400"; + # user = "postgres"; + # group = "postgres"; + # source = cfg.keytab; + # }; + # }; + }; + + users.groups = { + ${cfg.socket-group} = { members = [ "postgres" ] ++ cfg.local-users; }; + }; + + services.postgresql = { + enable = true; + package = pkgs.postgresql_11_gssapi; + enableTCPIP = true; + ensureDatabases = mapAttrsToList (name: value: name) cfg.databases; + ensureUsers = ((mapAttrsToList (username: attrs: { + name = username; + ensurePermissions = userDatabaseAccess username attrs.databases; + }) cfg.users) ++ (flatten (mapAttrsToList (database: opts: + (map (username: { + name = username; + ensurePermissions = { "DATABASE ${database}" = "ALL PRIVILEGES"; }; + }) opts.users)) cfg.databases))); + + settings = { + krb_server_keyfile = cfg.keytab; + + ssl = true; + ssl_cert_file = cfg.ssl-certificate; + ssl_key_file = cfg.ssl-private-key; + + unix_socket_directories = cfg.socket-directory; + unix_socket_group = cfg.socket-group; + unix_socket_permissions = "0777"; + }; + + authentication = lib.mkForce '' + ${makeLocalUserPasswordEntries cfg.users} + + local all all ident + + # host-local + host all all 127.0.0.1/32 gss include_realm=0 krb_realm=${gssapi-realm} + host all all ::1/128 gss include_realm=0 krb_realm=${gssapi-realm} + + # local networks + ${makeNetworksEntry cfg.local-networks} + ''; + + dataDir = mkIf (cfg.state-directory != null) cfg.state-directory; + }; + + systemd = { + + tmpfiles.rules = optional (cfg.state-directory != null) (let + user = config.systemd.services.postgresql.serviceConfig.User; + in "d ${cfg.state-directory} 0700 ${user} - - -"); + + targets.${strip-ext cfg.systemd-target} = { + description = "Postgresql and associated systemd services."; + }; + + services = { + postgresql-password-setter = let + passwords-script = passwords-setter-script cfg.users; + password-wrapper-script = + pkgs.writeScript "password-script-wrapper.sh" '' + TMPDIR=$(${pkgs.coreutils}/bin/mktemp -d -t postgres-XXXXXXXXXX) + echo "using temp dir $TMPDIR" + PASSWORD_SQL_FILE=$TMPDIR/user-passwords.sql + echo "password file $PASSWORD_SQL_FILE" + touch $PASSWORD_SQL_FILE + chown ${config.services.postgresql.superUser} $PASSWORD_SQL_FILE + chmod go-rwx $PASSWORD_SQL_FILE + ${passwords-script} $PASSWORD_SQL_FILE + echo "executing $PASSWORD_SQL_FILE" + ${pkgs.postgresql}/bin/psql --port ${ + toString config.services.postgresql.port + } -d postgres -f $PASSWORD_SQL_FILE + echo rm $PASSWORD_SQL_FILE + echo "Postgresql user passwords set."; + exit 0 + ''; + + in { + description = + "A service to set postgresql user passwords after the server has started."; + after = [ "postgresql.service" ] ++ cfg.required-services; + requires = [ "postgresql.service" ] ++ cfg.required-services; + serviceConfig = { + Type = "oneshot"; + User = config.services.postgresql.superUser; + }; + partOf = [ cfg.systemd-target ]; + script = "${password-wrapper-script}"; + }; + + postgresql = { + requires = cfg.required-services; + after = cfg.required-services; + partOf = [ cfg.systemd-target ]; + + postStart = let + allow-user-login = user: "ALTER ROLE ${user} WITH LOGIN;"; + + extra-settings-sql = pkgs.writeText "settings.sql" '' + ${concatStringsSep "\n" + (map allow-user-login (mapAttrsToList (key: val: key) cfg.users))} + ${usersAccessSql cfg.users} + ''; + in '' + ${pkgs.postgresql}/bin/psql --port ${ + toString config.services.postgresql.port + } -d postgres -f ${extra-settings-sql} + ${pkgs.coreutils}/bin/chgrp ${cfg.socket-group} ${cfg.socket-directory}/.s.PGSQL* + ''; + + postStop = concatStringsSep "\n" cfg.cleanup-tasks; + }; + }; + }; + }; +} diff --git a/lib/fudo/prometheus.nix b/lib/fudo/prometheus.nix new file mode 100644 index 0000000..450baaf --- /dev/null +++ b/lib/fudo/prometheus.nix @@ -0,0 +1,207 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + inherit (lib.strings) concatStringsSep; + cfg = config.fudo.prometheus; + +in { + + options.fudo.prometheus = { + enable = mkEnableOption "Fudo Prometheus Data-Gathering Server"; + + service-discovery-dns = mkOption { + type = with types; attrsOf (listOf str); + description = '' + A map of exporter type to a list of domains to use for service discovery. + ''; + example = { + node = [ "node._metrics._tcp.my-domain.com" ]; + postfix = [ "postfix._metrics._tcp.my-domain.com" ]; + }; + default = { + dovecot = []; + node = []; + postfix = []; + rspamd = []; + }; + }; + + static-targets = mkOption { + type = with types; attrsOf (listOf str); + description = '' + A map of exporter type to a list of host:ports from which to collect metrics. + ''; + example = { + node = [ "my-host.my-domain:1111" ]; + }; + default = { + dovecot = []; + node = []; + postfix = []; + rspamd = []; + }; + }; + + docker-hosts = mkOption { + type = with types; listOf str; + description = '' + A list of explicit docker targets from which to gather node data. + ''; + default = []; + }; + + push-url = mkOption { + type = with types; nullOr str; + description = '' + The that services can use to manually push data. + ''; + default = null; + }; + + push-address = mkOption { + type = with types; nullOr str; + description = '' + The address on which to listen for incoming data. + ''; + default = null; + }; + + hostname = mkOption { + type = with types; str; + description = "The hostname upon which Prometheus will serve."; + example = "my-metrics-server.fudo.org"; + }; + }; + + config = mkIf cfg.enable { + services.nginx = { + enable = true; + + virtualHosts = { + "${cfg.hostname}" = { + enableACME = true; + forceSSL = true; + + locations."/" = { + proxyPass = "http://127.0.0.1:9090"; + + extraConfig = let + local-networks = config.instance.local-networks; + in '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + + ${optionalString ((length local-networks) > 0) + (concatStringsSep "\n" (map (network: "allow ${network};") local-networks)) + "\ndeny all;"} + ''; + }; + }; + }; + }; + + services.prometheus = { + + enable = true; + + webExternalUrl = "https://${cfg.hostname}"; + + listenAddress = "127.0.0.1"; + port = 9090; + + scrapeConfigs = [ + { + job_name = "docker"; + honor_labels = false; + static_configs = [ + { + targets = cfg.docker-hosts; + } + ]; + } + + { + job_name = "node"; + scheme = "https"; + metrics_path = "/metrics/node"; + honor_labels = false; + dns_sd_configs = [ + { + names = cfg.service-discovery-dns.node; + } + ]; + static_configs = [ + { + targets = cfg.static-targets.node; + } + ]; + } + + { + job_name = "dovecot"; + scheme = "https"; + metrics_path = "/metrics/dovecot"; + honor_labels = false; + dns_sd_configs = [ + { + names = cfg.service-discovery-dns.dovecot; + } + ]; + static_configs = [ + { + targets = cfg.static-targets.dovecot; + } + ]; + } + + { + job_name = "postfix"; + scheme = "https"; + metrics_path = "/metrics/postfix"; + honor_labels = false; + dns_sd_configs = [ + { + names = cfg.service-discovery-dns.postfix; + } + ]; + static_configs = [ + { + targets = cfg.static-targets.postfix; + } + ]; + } + + { + job_name = "rspamd"; + scheme = "https"; + metrics_path = "/metrics/rspamd"; + honor_labels = false; + dns_sd_configs = [ + { + names = cfg.service-discovery-dns.rspamd; + } + ]; + static_configs = [ + { + targets = cfg.static-targets.rspamd; + } + ]; + } + ]; + + pushgateway = { + enable = if (cfg.push-url != null) then true else false; + web = { + external-url = if cfg.push-url == null then + cfg.push-address + else + cfg.push-url; + listen-address = cfg.push-address; + }; + }; + }; + }; +} diff --git a/lib/fudo/secrets.nix b/lib/fudo/secrets.nix new file mode 100644 index 0000000..344371a --- /dev/null +++ b/lib/fudo/secrets.nix @@ -0,0 +1,221 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.secrets; + + encrypt-on-disk = { secret-name, target-host, target-pubkey, source-file }: + pkgs.stdenv.mkDerivation { + name = "${target-host}-${secret-name}-secret"; + phases = "installPhase"; + buildInputs = [ pkgs.age ]; + installPhase = '' + age -a -r "${target-pubkey}" -o $out ${source-file} + ''; + }; + + decrypt-script = { secret-name, source-file, target-host, target-file + , host-master-key, user, group, permissions }: + pkgs.writeShellScript "decrypt-fudo-secret-${target-host}-${secret-name}.sh" '' + rm -f ${target-file} + touch ${target-file} + chown ${user}:${group} ${target-file} + chmod ${permissions} ${target-file} + # NOTE: silly hack because sometimes age leaves a blank line + # Only include lines with at least one non-space character + SRC=$(mktemp fudo-secret-${target-host}-${secret-name}.XXXXXXXX) + cat ${encrypt-on-disk { + inherit secret-name source-file target-host; + target-pubkey = host-master-key.public-key; + }} | grep "[^ ]" > $SRC + age -d -i ${host-master-key.key-path} -o ${target-file} $SRC + rm -f $SRC + ''; + + secret-service = target-host: secret-name: + { source-file, target-file, user, group, permissions, ... }: { + description = "decrypt secret ${secret-name} for ${target-host}."; + wantedBy = [ "default.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = let + host-master-key = config.fudo.hosts.${target-host}.master-key; + in decrypt-script { + inherit secret-name source-file target-host target-file host-master-key + user group permissions; + }; + }; + path = [ pkgs.age ]; + }; + + secretOpts = { name, ... }: { + options = with types; { + source-file = mkOption { + type = path; # CAREFUL: this will copy the file to nixstore...keep on deploy host + description = "File from which to load the secret. If unspecified, a random new password will be generated."; + default = "${generate-secret name}/passwd"; + }; + + target-file = mkOption { + type = str; + description = + "Target file on the host; the secret will be decrypted to this file."; + }; + + user = mkOption { + type = str; + description = "User (on target host) to which the file will belong."; + }; + + group = mkOption { + type = str; + description = "Group (on target host) to which the file will belong."; + default = "nogroup"; + }; + + permissions = mkOption { + type = str; + description = "Permissions to set on the target file."; + default = "0400"; + }; + + metadata = mkOption { + type = attrsOf anything; + description = "Arbitrary metadata associated with this secret."; + default = {}; + }; + }; + }; + + nix-build-users = let usernames = attrNames config.users.users; + in filter (user: (builtins.match "^nixbld[0-9]{1,2}$" user) != null) + usernames; + + generate-secret = name: pkgs.stdenv.mkDerivation { + name = "${name}-generated-passwd"; + + phases = [ "installPhase" ]; + + buildInputs = with pkgs; [ pwgen ]; + + buildPhase = '' + echo "${name}-${config.instance.build-timestamp}" >> file.txt + pwgen --secure --symbols --num-passwords=1 --sha1=file.txt 40 > passwd + rm -f file.txt + ''; + + installPhase = '' + mkdir $out + mv passwd $out/passwd + ''; + }; + +in { + options.fudo.secrets = with types; { + enable = mkOption { + type = bool; + description = "Include secrets in the build (disable when secrets are unavailable)"; + default = true; + }; + + host-secrets = mkOption { + type = attrsOf (attrsOf (submodule secretOpts)); + description = "Map of hosts to host secrets"; + default = { }; + }; + + host-deep-secrets = mkOption { + type = attrsOf (attrsOf (submodule secretOpts)); + description = '' + Secrets that are only passed during deployment. + + These secrets will be passed as nixops deployment secrets, + _unlike_ regular secrets that are passed to hosts as part of + the nixops store, but encrypted with the host SSH key. Regular + secrets are kept secret from normal users. These secrets will + be kept secret from _everybody_. However, they won't be + available on the host at boot until a new deployment occurs. + ''; + default = { }; + }; + + secret-users = mkOption { + type = listOf str; + description = "List of users with read-access to secrets."; + default = [ ]; + }; + + secret-group = mkOption { + type = str; + description = "Group to which secrets will belong."; + default = "nixops-secrets"; + }; + + secret-paths = mkOption { + type = listOf str; + description = + "Paths which contain (only) secrets. The contents will be reabable by the secret-group."; + default = [ ]; + }; + }; + + config = mkIf cfg.enable { + users.groups = { + ${cfg.secret-group} = { + members = cfg.secret-users ++ nix-build-users; + }; + }; + + systemd = let + hostname = config.instance.hostname; + + host-secrets = if (hasAttr hostname cfg.host-secrets) then + cfg.host-secrets.${hostname} + else + { }; + + host-secret-services = mapAttrs' (secret: secretOpts: + (nameValuePair "fudo-secret-${hostname}-${secret}" + (secret-service hostname secret secretOpts))) host-secrets; + + trace-all = obj: builtins.trace obj obj; + + host-secret-paths = mapAttrsToList + (secret: secretOpts: + let perms = if secretOpts.group != "nobody" then "550" else "500"; + in "d ${dirOf secretOpts.target-file} ${perms} ${secretOpts.user} ${secretOpts.group} - -") + host-secrets; + + build-secret-paths = + map (path: "d '${path}' - root ${cfg.secret-group} - -") + cfg.secret-paths; + + in { + tmpfiles.rules = host-secret-paths ++ build-secret-paths; + + services = host-secret-services // { + fudo-secrets-watcher = mkIf (length cfg.secret-paths > 0) { + wantedBy = [ "default.target" ]; + description = + "Ensure access for group ${cfg.secret-group} to fudo secret paths."; + serviceConfig = { + ExecStart = pkgs.writeShellScript "fudo-secrets-watcher.sh" + (concatStringsSep "\n" (map (path: '' + chown -R root:${cfg.secret-group} ${path} + chmod -R u=rwX,g=rX,o= ${path} + '') cfg.secret-paths)); + }; + }; + }; + + paths.fudo-secrets-watcher = mkIf (length cfg.secret-paths > 0) { + wantedBy = [ "default.target" ]; + description = "Watch fudo secret paths, and correct perms on changes."; + pathConfig = { + PathChanged = cfg.secret-paths; + Unit = "fudo-secrets-watcher.service"; + }; + }; + }; + }; +} diff --git a/lib/fudo/secure-dns-proxy.nix b/lib/fudo/secure-dns-proxy.nix new file mode 100644 index 0000000..d0afd24 --- /dev/null +++ b/lib/fudo/secure-dns-proxy.nix @@ -0,0 +1,103 @@ +{ lib, pkgs, config, ... }: + +with lib; +let + cfg = config.fudo.secure-dns-proxy; + + fudo-lib = import ../fudo-lib.nix { lib = lib; }; + +in { + options.fudo.secure-dns-proxy = with types; { + enable = + mkEnableOption "Enable a DNS server using an encrypted upstream source."; + + listen-port = mkOption { + type = port; + description = "Port on which to listen for DNS queries."; + default = 53; + }; + + upstream-dns = mkOption { + type = listOf str; + description = '' + The upstream DNS services to use, in a format useable by dnsproxy. + + See: https://github.com/AdguardTeam/dnsproxy + ''; + default = [ "https://cloudflare-dns.com/dns-query" ]; + }; + + bootstrap-dns = mkOption { + type = str; + description = + "A simple DNS server from which HTTPS DNS can be bootstrapped, if necessary."; + default = "1.1.1.1"; + }; + + listen-ips = mkOption { + type = listOf str; + description = "A list of local IP addresses on which to listen."; + default = [ "0.0.0.0" ]; + }; + + allowed-networks = mkOption { + type = nullOr (listOf str); + description = + "List of networks with which this job is allowed to communicate."; + default = null; + }; + + user = mkOption { + type = str; + description = "User as which to run secure DNS proxy."; + default = "secure-dns-proxy"; + }; + + group = mkOption { + type = str; + description = "Group as which to run secure DNS proxy."; + default = "secure-dns-proxy"; + }; + }; + + config = mkIf cfg.enable (let + upgrade-perms = cfg.listen-port <= 1024; + in { + users = mkIf upgrade-perms { + users = { + ${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; + }; + + groups = { + ${cfg.group} = { + members = [ cfg.user ]; + }; + }; + }; + + fudo.system.services.secure-dns-proxy = { + description = "DNS Proxy for secure DNS-over-HTTPS lookups."; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + privateNetwork = false; + requiredCapabilities = mkIf upgrade-perms [ "CAP_NET_BIND_SERVICE" ]; + restartWhen = "always"; + addressFamilies = [ "AF_INET" "AF_INET6" ]; + networkWhitelist = cfg.allowed-networks; + user = mkIf upgrade-perms cfg.user; + group = mkIf upgrade-perms cfg.group; + + execStart = let + upstreams = map (upstream: "-u ${upstream}") cfg.upstream-dns; + upstream-line = concatStringsSep " " upstreams; + listen-line = + concatStringsSep " " (map (listen: "-l ${listen}") cfg.listen-ips); + in "${pkgs.dnsproxy}/bin/dnsproxy -p ${ + toString cfg.listen-port + } ${upstream-line} ${listen-line} -b ${cfg.bootstrap-dns}"; + }; + }); +} diff --git a/lib/fudo/sites.nix b/lib/fudo/sites.nix new file mode 100644 index 0000000..384203f --- /dev/null +++ b/lib/fudo/sites.nix @@ -0,0 +1,240 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + site-name = config.fudo.hosts.${hostname}.site; + site-cfg = config.fudo.sites.${site-name}; + + site-hosts = filterAttrs (hostname: hostOpts: hostOpts.site == site-name) + config.fudo.hosts; + + siteOpts = { site, ... }: { + options = with types; { + site = mkOption { + type = str; + description = "Site name."; + default = site; + }; + + network = mkOption { + type = str; + description = "Network to be treated as local."; + }; + + dynamic-network = mkOption { + type = nullOr str; + description = "Network to be allocated by DHCP."; + default = null; + }; + + gateway-v4 = mkOption { + type = nullOr str; + description = "Gateway to use for public ipv4 internet access."; + default = null; + }; + + gateway-v6 = mkOption { + type = nullOr str; + description = "Gateway to use for public ipv6 internet access."; + default = null; + }; + + local-groups = mkOption { + type = listOf str; + description = "List of groups which should exist at this site."; + default = [ ]; + }; + + local-users = mkOption { + type = listOf str; + description = + "List of users which should exist on all hosts at this site."; + default = [ ]; + }; + + local-admins = mkOption { + type = listOf str; + description = + "List of admin users which should exist on all hosts at this site."; + default = [ ]; + }; + + enable-monitoring = + mkEnableOption "Enable site-wide monitoring with prometheus."; + + nameservers = mkOption { + type = listOf str; + description = "List of nameservers to be used by hosts at this site."; + default = [ ]; + }; + + timezone = mkOption { + type = str; + description = "Timezone of the site."; + example = "America/Winnipeg"; + }; + + deploy-pubkeys = mkOption { + type = nullOr (listOf str); + description = "SSH pubkey of site deploy key. Used by dropbear daemon."; + default = null; + }; + + enable-ssh-backdoor = mkOption { + type = bool; + description = + "Enable a backup SSH server in case of failures of the primary."; + default = true; + }; + + dropbear-rsa-key-path = mkOption { + type = str; + description = "Location of Dropbear RSA key."; + default = "/etc/dropbear/host_rsa_key"; + }; + + dropbear-ecdsa-key-path = mkOption { + type = str; + description = "Location of Dropbear ECDSA key."; + default = "/etc/dropbear/host_ecdsa_key"; + }; + + dropbear-ssh-port = mkOption { + type = port; + description = "Port to be used for the backup SSH server."; + default = 2112; + }; + + enable-distributed-builds = + mkEnableOption "Enable distributed builds for the site."; + + build-servers = mkOption { + type = attrsOf (submodule buildServerOpts); + description = + "List of hosts to be used as build servers for the local site."; + default = { }; + example = { + my-build-host = { + port = 22; + systems = [ "i686-linux" "x86_64-linux" ]; + build-user = "my-builder"; + }; + }; + }; + + local-networks = mkOption { + type = listOf str; + description = "List of networks to consider local at this site."; + default = [ ]; + }; + + mail-server = mkOption { + type = str; + description = "Hostname of the mail server to use for this site."; + }; + }; + }; + + buildServerOpts = { hostname, ... }: { + options = with types; { + port = mkOption { + type = port; + description = "SSH port at which to contact the server."; + default = 22; + }; + + systems = mkOption { + type = listOf str; + description = + "A list of systems for which this build server can build."; + default = [ "i686-linux" "x86_64-linux" ]; + }; + + max-jobs = mkOption { + type = int; + description = "Max build allowed per-system."; + default = 1; + }; + + speed-factor = mkOption { + type = int; + description = "Weight to give this server, i.e. it's relative speed."; + default = 1; + }; + + supported-features = mkOption { + type = listOf str; + description = "List of features supported by this server."; + default = [ ]; + }; + + build-user = mkOption { + type = str; + description = "User as which to run distributed builds."; + default = "nix-site-builder"; + }; + }; + }; + +in { + options.fudo.sites = mkOption { + type = with types; attrsOf (submodule siteOpts); + description = "Site configurations for all sites known to the system."; + default = { }; + }; + + config = { + networking.firewall.allowedTCPPorts = + mkIf site-cfg.enable-ssh-backdoor [ site-cfg.dropbear-ssh-port ]; + + systemd = mkIf site-cfg.enable-ssh-backdoor { + sockets = { + dropbear-deploy = { + wantedBy = [ "sockets.target" ]; + socketConfig = { + ListenStream = "0.0.0.0:${toString site-cfg.dropbear-ssh-port}"; + Accept = true; + }; + unitConfig = { restartIfChanged = true; }; + }; + }; + + services = { + dropbear-deploy-init = { + wantedBy = [ "multi-user.target" ]; + script = '' + if [ ! -d /etc/dropbear ]; then + mkdir /etc/dropbear + chmod 700 /etc/dropbear + fi + + if [ ! -f ${site-cfg.dropbear-rsa-key-path} ]; then + ${pkgs.dropbear}/bin/dropbearkey -t rsa -f ${site-cfg.dropbear-rsa-key-path} + ${pkgs.coreutils}/bin/chmod 0400 ${site-cfg.dropbear-rsa-key-path} + fi + + if [ ! -f ${site-cfg.dropbear-ecdsa-key-path} ]; then + ${pkgs.dropbear}/bin/dropbearkey -t ecdsa -f ${site-cfg.dropbear-ecdsa-key-path} + ${pkgs.coreutils}/bin/chmod 0400 ${site-cfg.dropbear-ecdsa-key-path} + fi + ''; + }; + + "dropbear-deploy@" = { + description = + "Per-connection service for deployment, using dropbear."; + requires = [ "dropbear-deploy-init.service" ]; + after = [ "network.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = + "${pkgs.dropbear}/bin/dropbear -F -i -w -m -j -k -r ${site-cfg.dropbear-rsa-key-path} -r ${site-cfg.dropbear-ecdsa-key-path}"; + ExecReload = "${pkgs.utillinux}/bin/kill -HUP $MAINPID"; + StandardInput = "socket"; + }; + }; + }; + }; + }; +} diff --git a/lib/fudo/slynk.nix b/lib/fudo/slynk.nix new file mode 100644 index 0000000..8ae12ed --- /dev/null +++ b/lib/fudo/slynk.nix @@ -0,0 +1,70 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.slynk; + + initScript = port: load-paths: let + load-path-string = + concatStringsSep " " (map (path: "\"${path}\"") load-paths); + in pkgs.writeText "slynk.lisp" '' + (load (merge-pathnames "quicklisp/setup.lisp" (user-homedir-pathname))) + (ql:quickload :slynk) + (setf asdf:*central-registry* + (append asdf:*central-registry* + (list ${load-path-string}))) + (slynk:create-server :port ${toString port} :dont-close t) + (dolist (var '("LD_LIBRARY_PATH")) + (format t "~S: ~S~%" var (sb-unix::posix-getenv var))) + + (loop (sleep 60)) + ''; + + lisp-libs = with pkgs.lispPackages; [ + alexandria + asdf-package-system + asdf-system-connections + cl_plus_ssl + cl-ppcre + quicklisp + quri + uiop + usocket + ]; + +in { + options.fudo.slynk = { + enable = mkEnableOption "Enable Slynk emacs common lisp server."; + + port = mkOption { + type = types.int; + description = "Port on which to open a Slynk server."; + default = 4005; + }; + }; + + config = mkIf cfg.enable { + systemd.user.services.slynk = { + description = "Slynk Common Lisp server."; + + serviceConfig = let + load-paths = (map (pkg: "${pkg}/lib/common-lisp/") lisp-libs); + in { + ExecStartPre = "${pkgs.lispPackages.quicklisp}/bin/quicklisp init"; + ExecStart = "${pkgs.sbcl}/bin/sbcl --load ${initScript cfg.port load-paths}"; + Restart = "on-failure"; + PIDFile = "/run/slynk.$USERNAME.pid"; + }; + + path = with pkgs; [ + gcc + glibc # for getent + file + ]; + + environment = { + LD_LIBRARY_PATH = "${pkgs.openssl_1_1.out}/lib"; + }; + }; + }; +} diff --git a/lib/fudo/ssh.nix b/lib/fudo/ssh.nix new file mode 100644 index 0000000..3f1f965 --- /dev/null +++ b/lib/fudo/ssh.nix @@ -0,0 +1,25 @@ +{ config, lib, pkgs, ... }: + +with lib; +{ + config = { + programs.ssh.knownHosts = let + keyed-hosts = + filterAttrs (h: o: o.ssh-pubkeys != []) + config.fudo.hosts; + + crossProduct = f: list0: list1: + concatMap (el0: map (el1: f el0 el1) list1) list0; + + all-hostnames = hostname: opts: + [ hostname ] ++ + (crossProduct (host: domain: "${host}.${domain}") + ([ hostname ] ++ opts.aliases) + ([ opts.domain ] ++ opts.extra-domains)); + + in mapAttrs (hostname: hostOpts: { + publicKeyFile = builtins.head hostOpts.ssh-pubkeys; + hostNames = all-hostnames hostname hostOpts; + }) keyed-hosts; + }; +} diff --git a/lib/fudo/system-networking.nix b/lib/fudo/system-networking.nix new file mode 100644 index 0000000..07c13c5 --- /dev/null +++ b/lib/fudo/system-networking.nix @@ -0,0 +1,168 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.system; + + portMappingOpts = { name, ... }: { + options = with types; { + internal-port = mkOption { + type = port; + description = "Port on localhost to recieve traffic"; + }; + external-port = mkOption { + type = port; + description = "External port on which to listen for traffic."; + }; + protocols = mkOption { + type = listOf str; + description = + "Protocols for which to forward ports. Default is tcp-only."; + default = [ "tcp" ]; + }; + }; + }; + +in { + options.fudo.system = with types; { + internal-port-map = mkOption { + type = attrsOf (submodule portMappingOpts); + description = + "Sets of external ports to internal (i.e. localhost) ports to forward."; + default = { }; + example = { + sshmap = { + internal-port = 2222; + external-port = 22; + protocol = "udp"; + }; + }; + }; + + # DO THIS MANUALLY since NixOS sux at making a reasonable /etc/hosts + hostfile-entries = mkOption { + type = attrsOf (listOf str); + description = "Map of extra IP addresses to hostnames for /etc/hosts"; + default = {}; + example = { + "10.0.0.3" = [ "my-host" "my-host.my.domain" ]; + }; + }; + }; + + config = mkIf (cfg.internal-port-map != { }) { + # FIXME: FUCK ME THIS IS WAY HARDER THAN IT SHOULD BE + # boot.kernel.sysctl = mkIf (cfg.internal-port-map != { }) { + # "net.ipv4.conf.all.route_localnet" = "1"; + # }; + + # fudo.system.services.forward-internal-ports = let + # ip-line = op: src-port: target-port: protocol: '' + # ${ipt} -t nat -${op} PREROUTING -p ${protocol} --dport ${ + # toString src-port + # } -j REDIRECT --to-ports ${toString target-port} + # ${ipt} -t nat -${op} OUTPUT -p ${protocol} -s lo --dport ${ + # toString src-port + # } -j REDIRECT --to-ports ${toString target-port} + # ''; + + # ip-forward-line = ip-line "I"; + + # ip-unforward-line = ip-line "D"; + + # traceOut = obj: builtins.trace obj obj; + + # concatMapAttrsToList = f: attrs: concatLists (mapAttrsToList f attrs); + + # portmap-entries = concatMapAttrsToList (name: opts: + # map (protocol: { + # src = opts.external-port; + # target = opts.internal-port; + # protocol = protocol; + # }) opts.protocols) cfg.internal-port-map; + + # make-entries = f: { src, target, protocol, ... }: f src target protocol; + + # forward-entries = map (make-entries ip-forward-line) portmap-entries; + + # unforward-entries = map (make-entries ip-unforward-line) portmap-entries; + + # forward-ports-script = pkgs.writeShellScript "forward-internal-ports.sh" + # (concatStringsSep "\n" forward-entries); + + # unforward-ports-script = + # pkgs.writeShellScript "unforward-internal-ports.sh" + # (concatStringsSep "\n" + # (map (make-entries ip-unforward-line) portmap-entries)); + # in { + # wantedBy = [ "multi-user.target" ]; + # after = [ "firewall.service" "nat.service" ]; + # type = "oneshot"; + # description = "Rules for forwarding external ports to local ports."; + # execStart = "${forward-ports-script}"; + # execStop = "${unforward-ports-script}"; + # requiredCapabilities = + # [ "CAP_DAC_READ_SEARCH" "CAP_NET_ADMIN" "CAP_NET_RAW" ]; + # }; + + # networking.firewall = let + # iptables = "ip46tables"; + # ip-forward-line = protocols: internal: external: + # concatStringsSep "\n" (map (protocol: '' + # ${iptables} -t nat -I PREROUTING -p ${protocol} --dport ${ + # toString external + # } -j REDIRECT --to-ports ${toString internal} + # ${iptables} -t nat -I OUTPUT -s lo -p ${protocol} --dport ${ + # toString external + # } -j REDIRECT --to-ports ${toString internal} + # '') protocols); + + # ip-unforward-line = protocols: internal: external: + # concatStringsSep "\n" (map (protocol: '' + # ${iptables} -t nat -D PREROUTING -p ${protocol} --dport ${ + # toString external + # } -j REDIRECT --to-ports ${toString internal} + # ${iptables} -t nat -D OUTPUT -s lo -p ${protocol} --dport ${ + # toString external + # } -j REDIRECT --to-ports ${toString internal} + # '') protocols); + # in { + # enable = true; + + # extraCommands = concatStringsSep "\n" (mapAttrsToList (name: opts: + # ip-forward-line opts.protocols opts.internal-port opts.external-port) + # cfg.internal-port-map); + + # extraStopCommands = concatStringsSep "\n" (mapAttrsToList (name: opts: + # ip-unforward-line opts.protocols opts.internal-port opts.external-port) + # cfg.internal-port-map); + # }; + + # networking.nat.forwardPorts = + # let portmaps = (attrValues opts.external-port); + # in concatMap (opts: + # map (protocol: { + # destination = "127.0.0.1:${toString opts.internal-port}"; + # sourcePort = opts.external-port; + # proto = protocol; + # }) opts.protocols) (attrValues cfg.internal-port-map); + + # services.xinetd = mkIf ((length (attrNames cfg.internal-port-map)) > 0) { + # enable = true; + # services = let + # svcs = mapAttrsToList (name: opts: opts // { name = name; }) + # cfg.internal-port-map; + # svcs-protocols = concatMap + # (svc: map (protocol: svc // { protocol = protocol; }) svc.protocols) + # svcs; + # in map (opts: { + # name = opts.name; + # unlisted = true; + # port = opts.external-port; + # server = "${pkgs.coreutils}/bin/false"; + # extraConfig = "redirect = localhost ${toString opts.internal-port}"; + # protocol = opts.protocol; + # }) svcs-protocols; + # }; + }; +} diff --git a/lib/fudo/system.nix b/lib/fudo/system.nix new file mode 100644 index 0000000..edd6844 --- /dev/null +++ b/lib/fudo/system.nix @@ -0,0 +1,500 @@ +{ pkgs, lib, config, ... }: + +with lib; +let + cfg = config.fudo.system; + + mkDisableOption = description: + mkOption { + type = types.bool; + default = true; + description = description; + }; + + isEmpty = lst: 0 == (length lst); + + serviceOpts = { name, ... }: + with types; { + options = { + after = mkOption { + type = listOf str; + description = "List of services to start before this one."; + default = [ ]; + }; + script = mkOption { + type = nullOr str; + description = "Simple shell script for the service to run."; + default = null; + }; + reloadScript = mkOption { + type = nullOr str; + description = "Script to run whenever the service is restarted."; + default = null; + }; + before = mkOption { + type = listOf str; + description = + "List of services before which this service should be started."; + default = [ ]; + }; + requires = mkOption { + type = listOf str; + description = + "List of services on which this service depends. If they fail to start, this service won't start."; + default = [ ]; + }; + preStart = mkOption { + type = nullOr str; + description = "Script to run prior to starting this service."; + default = null; + }; + postStart = mkOption { + type = nullOr str; + description = "Script to run after starting this service."; + default = null; + }; + preStop = mkOption { + type = nullOr str; + description = "Script to run prior to stopping this service."; + default = null; + }; + postStop = mkOption { + type = nullOr str; + description = "Script to run after stopping this service."; + default = null; + }; + requiredBy = mkOption { + type = listOf str; + description = + "List of services which require this service, and should fail without it."; + default = [ ]; + }; + wantedBy = mkOption { + type = listOf str; + default = [ ]; + description = + "List of services before which this service should be started."; + }; + environment = mkOption { + type = attrsOf str; + description = "Environment variables supplied to this service."; + default = { }; + }; + environment-file = mkOption { + type = nullOr str; + description = + "File containing environment variables supplied to this service."; + default = null; + }; + description = mkOption { + type = str; + description = "Description of the service."; + }; + path = mkOption { + type = listOf package; + description = + "A list of packages which should be in the service PATH."; + default = [ ]; + }; + restartIfChanged = + mkDisableOption "Restart the service if the definition changes."; + dynamicUser = mkDisableOption "Create a new user for this service."; + privateNetwork = mkDisableOption "Only allow access to localhost."; + privateUsers = + mkDisableOption "Don't allow access to system user list."; + privateDevices = mkDisableOption + "Restrict access to system devices other than basics."; + privateTmp = mkDisableOption "Limit service to a private tmp dir."; + protectControlGroups = + mkDisableOption "Don't allow service to modify control groups."; + protectClock = + mkDisableOption "Don't allow service to modify system clock."; + restrictSuidSgid = + mkDisableOption "Don't allow service to suid or sgid binaries."; + protectKernelTunables = + mkDisableOption "Don't allow service to modify kernel tunables."; + privateMounts = + mkDisableOption "Don't allow service to access mounted devices."; + protectKernelModules = mkDisableOption + "Don't allow service to load or evict kernel modules."; + protectHome = mkDisableOption "Limit access to home directories."; + protectHostname = + mkDisableOption "Don't allow service to modify hostname."; + protectKernelLogs = + mkDisableOption "Don't allow access to kernel logs."; + lockPersonality = mkDisableOption "Lock service 'personality'."; + restrictRealtime = + mkDisableOption "Restrict service from using realtime functionality."; + restrictNamespaces = + mkDisableOption "Restrict service from using namespaces."; + memoryDenyWriteExecute = mkDisableOption + "Restrict process from executing from writable memory."; + keyringMode = mkOption { + type = str; + default = "private"; + description = "Sharing state of process keyring."; + }; + requiredCapabilities = mkOption { + type = listOf (enum capabilities); + default = [ ]; + description = "List of capabilities granted to the service."; + }; + restartWhen = mkOption { + type = str; + default = "on-failure"; + description = "Conditions under which process should be restarted."; + }; + restartSec = mkOption { + type = int; + default = 10; + description = "Number of seconds to wait before restarting service."; + }; + execStart = mkOption { + type = nullOr str; + default = null; + description = "Command to run to launch the service."; + }; + execStop = mkOption { + type = nullOr str; + default = null; + description = "Command to run to launch the service."; + }; + protectSystem = mkOption { + type = enum [ "true" "false" "full" "strict" true false ]; + default = "full"; + description = + "Level of protection to apply to the system for this service."; + }; + addressFamilies = mkOption { + type = listOf (enum address-families); + default = [ ]; + description = "List of address families which the service can use."; + }; + workingDirectory = mkOption { + type = nullOr path; + default = null; + description = "Directory in which to launch the service."; + }; + user = mkOption { + type = nullOr str; + default = null; + description = "User as which to launch this service."; + }; + group = mkOption { + type = nullOr str; + default = null; + description = "Primary group as which to launch this service."; + }; + type = mkOption { + type = + enum [ "simple" "exec" "forking" "oneshot" "dbus" "notify" "idle" ]; + default = "simple"; + description = "Systemd service type of this service."; + }; + partOf = mkOption { + type = listOf str; + default = [ ]; + description = + "List of targets to which this service belongs (and with which it should be restarted)."; + }; + standardOutput = mkOption { + type = str; + default = "journal"; + description = "Destination of standard output for this service."; + }; + standardError = mkOption { + type = str; + default = "journal"; + description = "Destination of standard error for this service."; + }; + pidFile = mkOption { + type = nullOr str; + default = null; + description = "Service PID file."; + }; + networkWhitelist = mkOption { + type = nullOr (listOf str); + default = null; + description = + "A list of networks with which this process may communicate."; + }; + allowedSyscalls = mkOption { + type = listOf (enum syscalls); + default = [ ]; + description = "System calls which the service is permitted to make."; + }; + maximumUmask = mkOption { + type = str; + default = "0077"; + description = "Umask to apply to files created by the service."; + }; + startOnlyPerms = mkDisableOption "Disable perms after startup."; + onCalendar = mkOption { + type = nullOr str; + description = + "Schedule on which the job should be invoked. See: man systemd.time(7)."; + default = null; + }; + runtimeDirectory = mkOption { + type = nullOr str; + description = + "Directory created at runtime with perms for the service to read/write."; + default = null; + }; + readWritePaths = mkOption { + type = listOf str; + description = + "A list of paths to which the service will be allowed normal access, even if ProtectSystem=strict."; + default = [ ]; + }; + stateDirectory = mkOption { + type = nullOr str; + description = + "State directory for the service, available via STATE_DIRECTORY."; + default = null; + }; + cacheDirectory = mkOption { + type = nullOr str; + description = + "Cache directory for the service, available via CACHE_DIRECTORY."; + default = null; + }; + inaccessiblePaths = mkOption { + type = listOf str; + description = + "A list of paths which should be inaccessible to the service."; + default = [ "/home" "/root" ]; + }; + # noExecPaths = mkOption { + # type = listOf str; + # description = + # "A list of paths where the service will not be allowed to run executables."; + # default = [ "/home" "/root" "/tmp" "/var" ]; + # }; + readOnlyPaths = mkOption { + type = listOf str; + description = + "A list of paths to which will be read-only for the service."; + default = [ ]; + }; + execPaths = mkOption { + type = listOf str; + description = + "A list of paths where the service WILL be allowed to run executables."; + default = [ ]; + }; + }; + }; + + # See: man capabilities(7) + capabilities = [ + "CAP_AUDIT_CONTROL" + "CAP_AUDIT_READ" + "CAP_AUDIT_WRITE" + "CAP_BLOCK_SUSPEND" + "CAP_BPF" + "CAP_CHECKPOINT_RESTORE" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_DAC_READ_SEARCH" + "CAP_FOWNER" + "CAP_FSETID" + "CAP_IPC_LOCK" + "CAP_IPC_OWNER" + "CAP_KILL" + "CAP_LEASE" + "CAP_LINUX_IMMUTABLE" + "CAP_MAC_ADMIN" + "CAP_MAC_OVERRIDE" + "CAP_MKNOD" + "CAP_NET_ADMIN" + "CAP_NET_BIND_SERVICE" + "CAP_NET_BROADCAST" + "CAP_NET_RAW" + "CAP_PERFMON" + "CAP_SETGID" + "CAP_SETFCAP" + "CAP_SETPCAP" + "CAP_SETUID" + "CAP_SYS_ADMIN" + "CAP_SYS_BOOT" + "CAP_SYS_CHROOT" + "CAP_SYS_MODULE" + "CAP_SYS_NICE" + "CAP_SYS_PACCT" + "CAP_SYS_PTRACE" + "CAP_SYS_RAWIO" + "CAP_SYS_RESOURCE" + "CAP_SYS_TIME" + "CAP_SYS_TTY_CONFIG" + "CAP_SYSLOG" + "CAP_WAKE_ALARM" + ]; + + syscalls = [ + "@clock" + "@debug" + "@module" + "@mount" + "@raw-io" + "@reboot" + "@swap" + "@privileged" + "@resources" + "@cpu-emulation" + "@obsolete" + ]; + + address-families = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + + restrict-capabilities = allowed: + if (allowed == [ ]) then + "~${concatStringsSep " " capabilities}" + else + concatStringsSep " " allowed; + + restrict-syscalls = allowed: + if (allowed == [ ]) then + "~${concatStringsSep " " syscalls}" + else + concatStringsSep " " allowed; + + restrict-address-families = allowed: + if (allowed == [ ]) then [ "~AF_INET" "~AF_INET6" ] else allowed; + + dirOpts = { path, ... }: { + options = with types; { + user = mkOption { + type = str; + description = "User by whom the directory will be owned."; + default = "nobody"; + }; + group = mkOption { + type = str; + description = "Group by which the directory will be owned."; + default = "nogroup"; + }; + perms = mkOption { + type = str; + description = "Permission bits to apply to the directory."; + default = "0770"; + }; + }; + }; + +in { + options.fudo.system = with types; { + services = mkOption { + type = attrsOf (submodule serviceOpts); + description = "Fudo system service definitions, with secure defaults."; + default = { }; + }; + + tmpOnTmpfs = mkOption { + type = bool; + description = "Put tmp filesystem on tmpfs (needs enough RAM)."; + default = true; + }; + + ensure-directories = mkOption { + type = attrsOf (submodule dirOpts); + description = "A map of required directories to directory properties."; + default = { }; + }; + }; + + config = { + + systemd.timers = mapAttrs (name: opts: { + enable = true; + description = opts.description; + partOf = [ "${name}.timer" ]; + wantedBy = [ "timers.target" ]; + timerConfig = { OnCalendar = opts.onCalendar; }; + }) (filterAttrs (name: opts: opts.onCalendar != null) cfg.services); + + systemd.tmpfiles.rules = mapAttrsToList + (path: opts: "d ${path} ${opts.perms} ${opts.user} ${opts.group} - -") + cfg.ensure-directories; + + systemd.targets.fudo-init = { wantedBy = [ "multi-user.target" ]; }; + + systemd.services = mapAttrs (name: opts: { + enable = true; + script = mkIf (opts.script != null) opts.script; + reload = mkIf (opts.reloadScript != null) opts.reloadScript; + after = opts.after ++ [ "fudo-init.target" ]; + before = opts.before; + requires = opts.requires; + wantedBy = opts.wantedBy; + preStart = mkIf (opts.preStart != null) opts.preStart; + postStart = mkIf (opts.postStart != null) opts.postStart; + postStop = mkIf (opts.postStop != null) opts.postStop; + preStop = mkIf (opts.preStop != null) opts.preStop; + partOf = opts.partOf; + requiredBy = opts.requiredBy; + environment = opts.environment; + description = opts.description; + restartIfChanged = opts.restartIfChanged; + path = opts.path; + serviceConfig = { + PrivateNetwork = opts.privateNetwork; + PrivateUsers = mkIf (opts.user == null) opts.privateUsers; + PrivateDevices = opts.privateDevices; + PrivateTmp = opts.privateTmp; + PrivateMounts = opts.privateMounts; + ProtectControlGroups = opts.protectControlGroups; + ProtectKernelTunables = opts.protectKernelTunables; + ProtectKernelModules = opts.protectKernelModules; + ProtectSystem = opts.protectSystem; + ProtectHostname = opts.protectHostname; + ProtectHome = opts.protectHome; + ProtectClock = opts.protectClock; + ProtectKernelLogs = opts.protectKernelLogs; + KeyringMode = opts.keyringMode; + EnvironmentFile = + mkIf (opts.environment-file != null) opts.environment-file; + + # This is more complicated than it looks... + # CapabilityBoundingSet = restrict-capabilities opts.requiredCapabilities; + AmbientCapabilities = concatStringsSep " " opts.requiredCapabilities; + SecureBits = mkIf ((length opts.requiredCapabilities) > 0) "keep-caps"; + + DynamicUser = mkIf (opts.user == null) opts.dynamicUser; + Restart = opts.restartWhen; + WorkingDirectory = + mkIf (opts.workingDirectory != null) opts.workingDirectory; + RestrictAddressFamilies = + restrict-address-families opts.addressFamilies; + RestrictNamespaces = opts.restrictNamespaces; + User = mkIf (opts.user != null) opts.user; + Group = mkIf (opts.group != null) opts.group; + Type = opts.type; + StandardOutput = opts.standardOutput; + PIDFile = mkIf (opts.pidFile != null) opts.pidFile; + LockPersonality = opts.lockPersonality; + RestrictRealtime = opts.restrictRealtime; + ExecStart = mkIf (opts.execStart != null) opts.execStart; + ExecStop = mkIf (opts.execStop != null) opts.execStop; + MemoryDenyWriteExecute = opts.memoryDenyWriteExecute; + SystemCallFilter = restrict-syscalls opts.allowedSyscalls; + UMask = opts.maximumUmask; + IpAddressAllow = + mkIf (opts.networkWhitelist != null) opts.networkWhitelist; + IpAddressDeny = mkIf (opts.networkWhitelist != null) "any"; + LimitNOFILE = "49152"; + PermissionsStartOnly = opts.startOnlyPerms; + RuntimeDirectory = + mkIf (opts.runtimeDirectory != null) opts.runtimeDirectory; + CacheDirectory = mkIf (opts.cacheDirectory != null) opts.cacheDirectory; + StateDirectory = mkIf (opts.stateDirectory != null) opts.stateDirectory; + ReadWritePaths = opts.readWritePaths; + ReadOnlyPaths = opts.readOnlyPaths; + InaccessiblePaths = opts.inaccessiblePaths; + # Apparently not supported yet? + # NoExecPaths = opts.noExecPaths; + ExecPaths = opts.execPaths; + }; + }) config.fudo.system.services; + }; +} diff --git a/lib/fudo/users.nix b/lib/fudo/users.nix new file mode 100644 index 0000000..c95b6ed --- /dev/null +++ b/lib/fudo/users.nix @@ -0,0 +1,126 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + + user = import ../types/user.nix { inherit lib; }; + + list-includes = list: el: isNull (findFirst (this: this == el) null list); + + filterExistingUsers = users: group-members: + let user-list = attrNames users; + in filter (username: list-includes user-list username) group-members; + + hostname = config.instance.hostname; + host-cfg = config.fudo.hosts.${hostname}; + +in { + options = with types; { + fudo = { + users = mkOption { + type = attrsOf (submodule user.userOpts); + description = "Users"; + default = { }; + }; + + groups = mkOption { + type = attrsOf (submodule user.groupOpts); + description = "Groups"; + default = { }; + }; + + system-users = mkOption { + type = attrsOf (submodule user.systemUserOpts); + description = "System users (probably not what you're looking for!)"; + default = { }; + }; + }; + }; + + config = let + sys = config.instance; + in { + fudo.auth.ldap-server = { + users = filterAttrs + (username: userOpts: userOpts.ldap-hashed-passwd != null) + config.fudo.users; + + groups = config.fudo.groups; + + system-users = config.fudo.system-users; + }; + + programs.ssh.extraConfig = mkAfter '' + IdentityFile %h/.ssh/id_rsa + IdentityFile /etc/ssh/private_keys.d/%u.key + ''; + + environment.etc = mapAttrs' (username: userOpts: + nameValuePair + "ssh/private_keys.d/${username}" + { + text = concatStringsSep "\n" + (map (keypair: readFile keypair.public-key) + userOpts.ssh-keys); + }) + sys.local-users; + + users = { + users = mapAttrs (username: userOpts: { + isNormalUser = true; + uid = userOpts.uid; + createHome = true; + description = userOpts.common-name; + group = userOpts.primary-group; + home = if (userOpts.home-directory != null) then + userOpts.home-directory + else + "/home/${userOpts.primary-group}/${username}"; + hashedPassword = userOpts.login-hashed-passwd; + openssh.authorizedKeys.keys = userOpts.ssh-authorized-keys; + }) sys.local-users; + + groups = (mapAttrs (groupname: groupOpts: { + gid = groupOpts.gid; + members = filterExistingUsers sys.local-users groupOpts.members; + }) sys.local-groups) // { + wheel = { members = sys.local-admins; }; + docker = mkIf (host-cfg.docker-server) { members = sys.local-admins; }; + }; + }; + + services.nfs.idmapd.settings = let + local-domain = config.instance.local-domain; + local-admins = config.instance.local-admins; + local-users = config.instance.local-users; + local-realm = config.fudo.domains.${local-domain}.gssapi-realm; + in { + General = { + Verbosity = 10; + # Domain = local-domain; + "Local-Realms" = local-realm; + }; + Translation = { + GSS-Methods = "static"; + }; + Static = let + generate-admin-entry = admin: userOpts: + nameValuePair "${admin}/root@${local-realm}" "root"; + generate-user-entry = user: userOpts: + nameValuePair "${user}@${local-realm}" user; + + admin-entries = + mapAttrs' generate-admin-entry (getAttrs local-admins local-users); + user-entries = + mapAttrs' generate-user-entry local-users; + in admin-entries // user-entries; + }; + + # Group home directories have to exist, otherwise users can't log in + systemd.tmpfiles.rules = let + groups-with-members = attrNames + (filterAttrs (group: groupOpts: (length groupOpts.members) > 0) + sys.local-groups); + in map (group: "d /home/${group} 550 root ${group} - -") groups-with-members; + }; +} diff --git a/lib/fudo/vpn.nix b/lib/fudo/vpn.nix new file mode 100644 index 0000000..1ad39d2 --- /dev/null +++ b/lib/fudo/vpn.nix @@ -0,0 +1,126 @@ +{ pkgs, lib, config, ... }: + +with lib; +let + cfg = config.fudo.vpn; + + generate-pubkey-pkg = name: privkey: + pkgs.runCommand "wireguard-${name}-pubkey" { + WIREGUARD_PRIVATE_KEY = privkey; + } '' + mkdir $out + PUBKEY=$(echo $WIREGUARD_PRIVATE_KEY | ${pkgs.wireguard-tools}/bin/wg pubkey) + echo $PUBKEY > $out/pubkey.key + ''; + + generate-client-config = privkey-file: server-pubkey: network: server-ip: listen-port: dns-servers: '' + [Interface] + Address = ${ip.networkMinIp network} + PrivateKey = ${fileContents privkey-file} + ListenPort = ${toString listen-port} + DNS = ${concatStringsSep ", " dns-servers} + + [Peer] + PublicKey = ${server-pubkey} + Endpoint = ${server-ip}:${toString listen-port} + AllowedIps = 0.0.0.0/0, ::/0 + PersistentKeepalive = 25 + ''; + + generate-peer-entry = peer-name: peer-privkey-path: peer-allowed-ips: let + peer-pkg = generate-pubkey-pkg "client-${peer-name}" (fileContents peer-privkey-path); + pubkey-path = "${peer-pkg}/pubkey.key"; + in { + publicKey = fileContents pubkey-path; + allowedIPs = peer-allowed-ips; + }; + +in { + options.fudo.vpn = with types; { + enable = mkEnableOption "Enable Fudo VPN"; + + network = mkOption { + type = str; + description = "Network range to assign this interface."; + default = "10.100.0.0/16"; + }; + + private-key-file = mkOption { + type = str; + description = "Path to the secret key (generated with wg [genkey/pubkey])."; + example = "/path/to/secret.key"; + }; + + listen-port = mkOption { + type = port; + description = "Port on which to listen for incoming connections."; + default = 51820; + }; + + dns-servers = mkOption { + type = listOf str; + description = "A list of dns servers to pass to clients."; + default = ["1.1.1.1" "8.8.8.8"]; + }; + + server-ip = mkOption { + type = str; + description = "IP of this WireGuard server."; + }; + + peers = mkOption { + type = attrsOf str; + description = "A map of peers to shared private keys."; + default = {}; + example = { + peer0 = "/path/to/priv.key"; + }; + }; + }; + + config = mkIf cfg.enable { + environment.etc = let + peer-data = imap1 (i: peer:{ + name = peer.name; + privkey-path = peer.privkey-path; + network-range = let + base = ip.intToIpv4 + ((ip.ipv4ToInt (ip.getNetworkBase cfg.network)) + (i * 256)); + in "${base}/24"; + }) (mapAttrsToList (name: privkey-path: { + name = name; + privkey-path = privkey-path; + }) cfg.peers); + + server-pubkey-pkg = generate-pubkey-pkg "server-pubkey" (fileContents cfg.private-key-file); + + server-pubkey = fileContents "${server-pubkey-pkg}/pubkey.key"; + + in listToAttrs + (map (peer: nameValuePair "wireguard/clients/${peer.name}.conf" { + mode = "0400"; + user = "root"; + group = "root"; + text = generate-client-config + peer.privkey-path + server-pubkey + peer.network-range + cfg.server-ip + cfg.listen-port + cfg.dns-servers; + }) peer-data); + + networking.wireguard = { + enable = true; + interfaces.wgtun0 = { + generatePrivateKeyFile = false; + ips = [ cfg.network ]; + listenPort = cfg.listen-port; + peers = mapAttrsToList + (name: private-key: generate-peer-entry name private-key ["0.0.0.0/0" "::/0"]) + cfg.peers; + privateKeyFile = cfg.private-key-file; + }; + }; + }; +} diff --git a/lib/fudo/webmail.nix b/lib/fudo/webmail.nix new file mode 100644 index 0000000..240efeb --- /dev/null +++ b/lib/fudo/webmail.nix @@ -0,0 +1,385 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + hostname = config.instance.hostname; + + cfg = config.fudo.webmail; + + webmail-user = cfg.user; + webmail-group = cfg.group; + + base-data-path = "/run/rainloop"; + + concatMapAttrs = f: attrs: + foldr (a: b: a // b) {} (mapAttrsToList f attrs); + + fastcgi-conf = builtins.toFile "fastcgi.conf" '' + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + + fastcgi_param SCRIPT_NAME $fastcgi_script_name; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param DOCUMENT_URI $document_uri; + fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_param SERVER_PROTOCOL $server_protocol; + fastcgi_param REQUEST_SCHEME $scheme; + fastcgi_param HTTPS $https if_not_empty; + + fastcgi_param GATEWAY_INTERFACE CGI/1.1; + fastcgi_param SERVER_SOFTWARE nginx/$nginx_version; + + fastcgi_param REMOTE_ADDR $remote_addr; + fastcgi_param REMOTE_PORT $remote_port; + fastcgi_param SERVER_ADDR $server_addr; + fastcgi_param SERVER_PORT $server_port; + fastcgi_param SERVER_NAME $server_name; + + # PHP only, required if PHP was built with --enable-force-cgi-redirect + fastcgi_param REDIRECT_STATUS 200; + ''; + + site-packages = mapAttrs (site: site-cfg: + pkgs.rainloop-community.overrideAttrs (oldAttrs: { + # Not sure how to correctly specify this arg... + #dataPath = "${base-data-path}/${site}"; + + # Overwriting, to correctly create data dir + installPhase = '' + mkdir $out + cp -r rainloop/* $out + rm -rf $out/data + ln -s ${base-data-path}/${site} $out/data + ln -s ${site-cfg.favicon} $out/favicon.ico + ''; + })) cfg.sites; + + siteOpts = { site-host, ... }: with types; { + options = { + title = mkOption { + type = str; + description = "Webmail site title"; + example = "My Webmail"; + }; + + debug = mkOption { + type = bool; + description = "Turn debug logs on."; + default = false; + }; + + mail-server = mkOption { + type = str; + description = "Mail server from which to send & recieve email."; + default = "mail.fudo.org"; + }; + + favicon = mkOption { + type = str; + description = "URL of the site favicon"; + example = "https://www.somepage.com/fav.ico"; + }; + + messages-per-page = mkOption { + type = int; + description = "Default number of messages to show per page"; + default = 30; + }; + + max-upload-size = mkOption { + type = int; + description = "Size limit in MB for uploaded files"; + default = 30; + }; + + theme = mkOption { + type = str; + description = "Default theme to use for this webmail site."; + default = "Default"; + }; + + domain = mkOption { + type = str; + description = "Domain for which the server acts as webmail server"; + }; + + edit-mode = mkOption { + type = enum [ "Plain" "Html" "PlainForced" "HtmlForced" ]; + description = "Default text editing mode for email"; + default = "Html"; + }; + + layout-mode = mkOption { + type = enum [ "side" "bottom" ]; + description = "Layout mode to use for email preview."; + default = "side"; + }; + + enable-threading = mkOption { + type = bool; + description = "Whether to enable threading for email."; + default = true; + }; + + enable-mobile = mkOption { + type = bool; + description = "Whether to enable a mobile site view."; + default = true; + }; + + database = mkOption { + type = nullOr (submodule databaseOpts); + description = "Database configuration for storing contact data."; + example = { + name = "my_db"; + host = "db.domain.com"; + user = "my_user"; + password-file = /path/to/some/file.pw; + }; + default = null; + }; + + admin-email = mkOption { + type = str; + description = "Email of administrator of this site."; + default = "admin@fudo.org"; + }; + }; + }; + + databaseOpts = { ... }: with types; { + options = { + type = mkOption { + type = enum [ "pgsql" "mysql" ]; + description = "Driver to use when connecting to the database."; + default = "pgsql"; + }; + + hostname = mkOption { + type = str; + description = "Name of host running the database."; + example = "my-db.domain.com"; + }; + + port = mkOption { + type = int; + description = "Port on which the database server is listening."; + default = 5432; + }; + + name = mkOption { + type = str; + description = + "Name of the database containing contact info. must have access."; + default = "rainloop_webmail"; + }; + + user = mkOption { + type = str; + description = "User as which to connect to the database."; + default = "webmail"; + }; + + password-file = mkOption { + type = nullOr str; + description = '' + Password to use when connecting to the database. + + If unset, a random password will be generated. + ''; + }; + }; + }; + +in { + options.fudo.webmail = with types; { + enable = mkEnableOption "Enable a RainLoop webmail server."; + + sites = mkOption { + type = attrsOf (submodule siteOpts); + description = "A map of webmail sites to site configurations."; + example = { + "webmail.domain.com" = { + title = "My Awesome Webmail"; + layout-mode = "side"; + favicon = "/path/to/favicon.ico"; + admin-password = "shh-don't-tell"; + }; + }; + }; + + user = mkOption { + type = str; + description = "User as which webmail will run."; + default = "webmail-php"; + }; + + group = mkOption { + type = str; + description = "Group as which webmail will run."; + default = "webmail-php"; + }; + }; + + config = mkIf cfg.enable { + users = { + users = { + ${webmail-user} = { + isSystemUser = true; + description = "Webmail PHP FPM user"; + group = webmail-group; + }; + }; + groups = { + ${webmail-group} = { + members = [ webmail-user config.services.nginx.user ]; + }; + }; + }; + + security.acme.certs = mapAttrs + (site: site-cfg: { email = site-cfg.admin-email; }) + cfg.sites; + + services = { + phpfpm = { + pools.webmail = { + settings = { + "pm" = "dynamic"; + "pm.max_children" = 50; + "pm.start_servers" = 5; + "pm.min_spare_servers" = 1; + "pm.max_spare_servers" = 8; + }; + + phpOptions = '' + memory_limit = 500M + ''; + + # Not working....see chmod below + user = webmail-user; + group = webmail-group; + }; + }; + + nginx = { + enable = true; + + virtualHosts = mapAttrs (site: site-cfg: { + enableACME = true; + forceSSL = true; + + root = "${site-packages.${site}}"; + + locations = { + "/" = { index = "index.php"; }; + + "/data" = { + extraConfig = '' + deny all; + return 403; + ''; + }; + }; + + extraConfig = '' + location ~ \.php$ { + expires -1; + + include ${fastcgi-conf}; + fastcgi_index index.php; + fastcgi_pass unix:${config.services.phpfpm.pools.webmail.socket}; + } + ''; + }) cfg.sites; + }; + }; + + fudo.secrets.host-secrets.${hostname} = concatMapAttrs + (site: site-cfg: let + + site-config-file = builtins.toFile "${site}-rainloop.cfg" + (import ./include/rainloop.nix lib site site-cfg site-packages.${site}.version); + + domain-config-file = builtins.toFile "${site}-domain.cfg" '' + imap_host = "${site-cfg.mail-server}" + imap_port = 143 + imap_secure = "TLS" + imap_short_login = On + sieve_use = Off + sieve_allow_raw = Off + sieve_host = "" + sieve_port = 4190 + sieve_secure = "None" + smtp_host = "${site-cfg.mail-server}" + smtp_port = 587 + smtp_secure = "TLS" + smtp_short_login = On + smtp_auth = On + smtp_php_mail = Off + white_list = "" + ''; + in { + "${site}-site-config" = { + source-file = site-config-file; + target-file = "/var/run/webmail/rainloop/site-${site}-rainloop.cfg"; + user = cfg.user; + }; + + "${site}-domain-config" = { + source-file = domain-config-file; + target-file = "/var/run/webmail/rainloop/domain-${site}-rainloop.cfg"; + user = cfg.user; + }; + }) cfg.sites; + + # TODO: make this a fudo service + systemd.services = { + webmail-init = let + link-configs = concatStringsSep "\n" (mapAttrsToList (site: site-cfg: + let + cfg-file = config.fudo.secrets.host-secrets.${hostname}."${site}-site-config".target-file; + domain-cfg-file = config.fudo.secrets.host-secrets.${hostname}."${site}-domain-config".target-file; + in '' + ${pkgs.coreutils}/bin/mkdir -p ${base-data-path}/${site}/_data_/_default_/configs + ${pkgs.coreutils}/bin/cp ${cfg-file} ${base-data-path}/${site}/_data_/_default_/configs/application.ini + + ${pkgs.coreutils}/bin/mkdir -p ${base-data-path}/${site}/_data_/_default_/domains/ + ${pkgs.coreutils}/bin/cp ${domain-cfg-file} ${base-data-path}/${site}/_data_/_default_/domains/${site-cfg.domain}.ini + '') cfg.sites); + scriptPkg = (pkgs.writeScriptBin "webmail-init.sh" '' + #!${pkgs.bash}/bin/bash -e + ${link-configs} + ${pkgs.coreutils}/bin/chown -R ${webmail-user}:${webmail-group} ${base-data-path} + ${pkgs.coreutils}/bin/chmod -R u+w ${base-data-path} + ''); + in { + requiredBy = [ "nginx.service" ]; + description = + "Initialize webmail service directories prior to starting nginx."; + script = "${scriptPkg}/bin/webmail-init.sh"; + }; + + phpfpm-webmail-socket-perm = { + wantedBy = [ "multi-user.target" ]; + description = + "Change ownership of the phpfpm socket for webmail once it's started."; + requires = [ "phpfpm-webmail.service" ]; + after = [ "phpfpm.target" ]; + serviceConfig = { + ExecStart = '' + ${pkgs.coreutils}/bin/chown ${webmail-user}:${webmail-group} ${config.services.phpfpm.pools.webmail.socket} + ''; + }; + }; + + nginx = { + requires = + [ "webmail-init.service" "phpfpm-webmail-socket-perm.service" ]; + }; + }; + }; +} diff --git a/lib/fudo/wireless-networks.nix b/lib/fudo/wireless-networks.nix new file mode 100644 index 0000000..62ada27 --- /dev/null +++ b/lib/fudo/wireless-networks.nix @@ -0,0 +1,32 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + networkOpts = { network, ... }: { + options = { + network = mkOption { + type = types.str; + description = "Name of wireless network."; + default = network; + }; + + key = mkOption { + type = types.str; + description = "Secret key for wireless network."; + }; + }; + }; + +in { + options.fudo.wireless-networks = mkOption { + type = with types; attrsOf (submodule networkOpts); + description = "A map of wireless networks to attributes (including key)."; + default = { }; + }; + + config = { + networking.wireless.networks = + mapAttrs (network: networkOpts: { psk = networkOpts.key; }) + config.fudo.wireless-networks; + }; +} diff --git a/lib/informis/cl-gemini.nix b/lib/informis/cl-gemini.nix new file mode 100644 index 0000000..71c0a09 --- /dev/null +++ b/lib/informis/cl-gemini.nix @@ -0,0 +1,177 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.informis.cl-gemini; + + feedOpts = { ... }: with types; { + options = { + url = mkOption { + type = str; + description = "Base URI of the feed, i.e. the URI corresponding to the feed path."; + example = "gemini://my.server/path/to/feedfiles"; + }; + + title = mkOption { + type = str; + description = "Title of given feed."; + example = "My Fancy Feed"; + }; + + path = mkOption { + type = str; + description = "Path to Gemini files making up the feed."; + example = "/path/to/feed"; + }; + }; + }; + + ensure-certificates = hostname: user: key: cert: pkgs.writeShellScript "ensure-gemini-certificates.sh" '' + if [[ ! -e ${key} ]]; then + TARGET_CERT_DIR=$(${pkgs.coreutils}/bin/dirname ${cert}) + TARGET_KEY_DIR=$(${pkgs.coreutils}/bin/dirname ${key}) + if [[ ! -d $TARGET_CERT_DIR ]]; then mkdir -p $TARGET_CERT_DIR; fi + if [[ ! -d $TARGET_KEY_DIR ]]; then mkdir -p $TARGET_KEY_DIR; fi + ${pkgs.openssl}/bin/openssl req -new -subj "/CN=.${hostname}" -addext "subjectAltName = DNS:${hostname}, DNS:.${hostname}" -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 3650 -nodes -out ${cert} -keyout ${key} + ${pkgs.coreutils}/bin/chown -R ${user}:nogroup ${cert} + ${pkgs.coreutils}/bin/chown -R ${user}:nogroup ${key} + ${pkgs.coreutils}/bin/chmod 0444 ${cert} + ${pkgs.coreutils}/bin/chmod 0400 ${key} + fi + ''; + + generate-feeds = feeds: + let + feed-strings = mapAttrsToList (feed-name: opts: + "(cl-gemini:register-feed :name \"${feed-name}\" :title \"${opts.title}\" :path \"${opts.path}\" :base-uri \"${opts.url}\")") feeds; + in pkgs.writeText "gemini-local-feeds.lisp" (concatStringsSep "\n" feed-strings); + +in { + options.informis.cl-gemini = with types; { + enable = mkEnableOption "Enable the cl-gemini server."; + + port = mkOption { + type = port; + description = "Port on which to serve Gemini traffic."; + default = 1965; + }; + + hostname = mkOption { + type = str; + description = "Hostname at which the server is available (for generating the SSL certificate)."; + example = "my.hostname.com"; + }; + + user = mkOption { + type = str; + description = "User as which to run the cl-gemini server."; + default = "cl-gemini"; + }; + + server-ip = mkOption { + type = str; + description = "IP on which to serve Gemini traffic."; + example = "1.2.3.4"; + }; + + document-root = mkOption { + type = str; + description = "Root at which to look for gemini files."; + example = "/my/gemini/root"; + }; + + user-public = mkOption { + type = str; + description = "Subdirectory of user homes to check for gemini files."; + default = "gemini-public"; + }; + + ssl-private-key = mkOption { + type = str; + description = "Path to the pem-encoded server private key."; + example = "/path/to/secret/key.pem"; + default = "${config.users.users.cl-gemini.home}/private/server-key.pem"; + }; + + ssl-certificate = mkOption { + type = str; + description = "Path to the pem-encoded server public certificate."; + example = "/path/to/cert.pem"; + default = "${config.users.users.cl-gemini.home}/private/server-cert.pem"; + }; + + slynk-port = mkOption { + type = nullOr port; + description = "Port on which to open a slynk server, if any."; + default = null; + }; + + feeds = mkOption { + type = attrsOf (submodule feedOpts); + description = "Feeds to generate and make available (as eg. /feed/name.xml)."; + example = { + diary = { + title = "My Diary"; + path = "/path/to/my/gemfiles/"; + url = "gemini://my.host/blog-path/"; + }; + }; + default = {}; + }; + + textfiles-archive = mkOption { + type = str; + description = "A path containing only gemini & text files."; + example = "/path/to/textfiles/"; + }; + }; + + config = mkIf cfg.enable { + + networking.firewall.allowedTCPPorts = [ cfg.port ]; + + users.users = { + ${cfg.user} = { + isSystemUser = true; + group = "nogroup"; + createHome = true; + home = "/var/lib/${cfg.user}"; + }; + }; + + systemd.services = { + cl-gemini = { + description = "cl-gemini Gemini server (https://gemini.circumlunar.space/)"; + + serviceConfig = { + ExecStartPre = "${ensure-certificates cfg.hostname cfg.user cfg.ssl-private-key cfg.ssl-certificate}"; + ExecStart = "${pkgs.cl-gemini}/bin/launch-server.sh"; + Restart = "on-failure"; + PIDFile = "/run/cl-gemini.$USERNAME.uid"; + User = cfg.user; + }; + + environment = { + GEMINI_SLYNK_PORT = mkIf (cfg.slynk-port != null) (toString cfg.slynk-port); + GEMINI_LISTEN_IP = cfg.server-ip; + GEMINI_PRIVATE_KEY = cfg.ssl-private-key; + GEMINI_CERTIFICATE = cfg.ssl-certificate; + GEMINI_LISTEN_PORT = toString cfg.port; + GEMINI_DOCUMENT_ROOT = cfg.document-root; + GEMINI_TEXTFILES_ROOT = cfg.textfiles-archive; + GEMINI_FEEDS = "${generate-feeds cfg.feeds}"; + + CL_SOURCE_REGISTRY = "${pkgs.lib.fudo.lisp.lisp-source-registry pkgs.cl-gemini}"; + }; + + path = with pkgs; [ + gcc + file + getent + ]; + + wantedBy = [ "multi-user.target" ]; + }; + }; + }; +} diff --git a/lib/informis/default.nix b/lib/informis/default.nix new file mode 100644 index 0000000..702c6d3 --- /dev/null +++ b/lib/informis/default.nix @@ -0,0 +1,7 @@ +{ config, lib, pkgs, ... }: + +{ + imports = [ + ./cl-gemini.nix + ]; +} diff --git a/lib/instance.nix b/lib/instance.nix new file mode 100644 index 0000000..c70652b --- /dev/null +++ b/lib/instance.nix @@ -0,0 +1,122 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + user = import ./types/user.nix { inherit lib; }; + host = import ./types/host.nix { inherit lib; }; + +in { + options.instance = with types; { + hostname = mkOption { + type = str; + description = "Hostname of this specific host (without domain)."; + }; + + host-fqdn = mkOption { + type = str; + description = "Fully-qualified name of this host."; + }; + + build-timestamp = mkOption { + type = int; + description = "Timestamp associated with the build. Used for e.g. DNS serials."; + }; + + local-domain = mkOption { + type = str; + description = "Domain name of the current local host."; + }; + + local-profile = mkOption { + type = str; + description = "Profile name of the current local host."; + }; + + local-site = mkOption { + type = str; + description = "Site name of the current local host."; + }; + + local-admins = mkOption { + type = listOf str; + description = "List of users who should have admin access to the local host."; + }; + + local-groups = mkOption { + type = attrsOf (submodule user.groupOpts); + description = "List of groups which should be created on the local host."; + }; + + local-hosts = mkOption { + type = attrsOf (submodule host.hostOpts); + description = "List of hosts that should be considered local to the current host."; + }; + + local-users = mkOption { + type = attrsOf (submodule user.userOpts); + description = "List of users who should have access to the local host"; + }; + + local-networks = mkOption { + type = listOf str; + description = "Networks which are considered local to this host, site, or domain."; + }; + + build-seed = mkOption { + type = str; + description = "Seed used to generate configuration."; + }; + }; + + config = let + local-host = config.instance.hostname; + local-domain = config.fudo.hosts.${local-host}.domain; + local-site = config.fudo.hosts.${local-host}.site; + + host = config.fudo.hosts.${local-host}; + + host-user-list = host.local-users; + domain-user-list = config.fudo.domains."${local-domain}".local-users; + site-user-list = config.fudo.sites."${local-site}".local-users; + local-users = + getAttrs (host-user-list ++ domain-user-list ++ site-user-list) config.fudo.users; + + host-admin-list = host.local-admins; + domain-admin-list = config.fudo.domains."${local-domain}".local-admins; + site-admin-list = config.fudo.sites."${local-site}".local-admins; + local-admins = host-admin-list ++ domain-admin-list ++ site-admin-list; + + host-group-list = host.local-groups; + domain-group-list = config.fudo.domains."${local-domain}".local-groups; + site-group-list = config.fudo.sites."${local-site}".local-groups; + local-groups = + getAttrs (host-group-list ++ domain-group-list ++ site-group-list) + config.fudo.groups; + + local-hosts = + filterAttrs (host: hostOpts: hostOpts.site == local-site) config.fudo.hosts; + + local-networks = + host.local-networks ++ + config.fudo.domains.${local-domain}.local-networks ++ + config.fudo.sites.${local-site}.local-networks; + + local-profile = host.profile; + + host-fqdn = "${config.instance.hostname}.${local-domain}"; + + in { + instance = { + inherit + host-fqdn + local-domain + local-site + local-users + local-admins + local-groups + local-hosts + local-profile + local-networks; + }; + }; +} diff --git a/lib/dns.nix b/lib/lib/dns.nix similarity index 100% rename from lib/dns.nix rename to lib/lib/dns.nix diff --git a/lib/filesystem.nix b/lib/lib/filesystem.nix similarity index 100% rename from lib/filesystem.nix rename to lib/lib/filesystem.nix diff --git a/lib/ip.nix b/lib/lib/ip.nix similarity index 100% rename from lib/ip.nix rename to lib/lib/ip.nix diff --git a/lib/lisp.nix b/lib/lib/lisp.nix similarity index 100% rename from lib/lisp.nix rename to lib/lib/lisp.nix diff --git a/lib/network.nix b/lib/lib/network.nix similarity index 100% rename from lib/network.nix rename to lib/lib/network.nix diff --git a/lib/passwd.nix b/lib/lib/passwd.nix similarity index 100% rename from lib/passwd.nix rename to lib/lib/passwd.nix diff --git a/lib/types/host.nix b/lib/types/host.nix new file mode 100644 index 0000000..c01d0a4 --- /dev/null +++ b/lib/types/host.nix @@ -0,0 +1,305 @@ +{ lib, ... }: + +with lib; +let + passwd = import ../passwd.nix { inherit lib; }; + +in rec { + encryptedFSOpts = { ... }: let + mountpoint = { mp, ... }: { + options = with types; { + mountpoint = mkOption { + type = str; + description = "Path at which to mount the filesystem."; + default = mp; + }; + + options = mkOption { + type = listOf str; + description = "List of filesystem options specific to this mountpoint (eg: subvol)."; + }; + + group = mkOption { + type = nullOr str; + description = "Group to which the mountpoint should belong."; + default = null; + }; + + users = mkOption { + type = listOf str; + description = '' + List of users who should have access to the filesystem. + + Requires a group to be set. + ''; + default = [ ]; + }; + + world-readable = mkOption { + type = bool; + description = "Whether to leave the top level world-readable."; + default = true; + }; + }; + }; + in { + options = with types; { + encrypted-device = mkOption { + type = str; + description = "Path to the encrypted device."; + }; + + key-path = mkOption { + type = str; + description = '' + Path at which to locate the key file. + + The filesystem will be decrypted and mounted once available."; + ''; + }; + + filesystem-type = mkOption { + type = str; + description = "Filesystem type of the decrypted filesystem."; + }; + + options = mkOption { + type = listOf str; + description = "List of filesystem options with which to mount."; + }; + + mountpoints = mkOption { + type = attrsOf (submodule mountpoint); + description = "A map of mountpoints for this filesystem to fs options. Multiple to support btrfs."; + default = {}; + }; + }; + }; + + masterKeyOpts = { ... }: { + options = with types; { + key-path = mkOption { + type = str; + description = "Path of the host master key file, used to decrypt secrets."; + }; + + public-key = mkOption { + type = str; + description = "Public key used during deployment to decrypt secrets for the host."; + }; + }; + }; + + hostOpts = { name, ... }: let + hostname = name; + in { + options = with types; { + master-key = mkOption { + type = nullOr (submodule masterKeyOpts); + description = "Public key for the host master key, used by the host to decrypt secrets."; + }; + + domain = mkOption { + type = str; + description = + "Primary domain to which the host belongs, in the form of a domain name."; + default = "fudo.org"; + }; + + extra-domains = mkOption { + type = listOf str; + description = "Extra domain in which this host is reachable."; + default = [ ]; + }; + + aliases = mkOption { + type = listOf str; + description = + "Host aliases used by the current host. Note this will be multiplied with extra-domains."; + default = [ ]; + }; + + site = mkOption { + type = str; + description = "Site at which the host is located."; + default = "unsited"; + }; + + local-networks = mkOption { + type = listOf str; + description = + "A list of networks to be considered trusted by this host."; + default = [ "127.0.0.0/8" ]; + }; + + profile = mkOption { + type = listOf (enum ["desktop" "server" "laptop"]); + description = + "The profile to be applied to the host, determining what software is included."; + }; + + admin-email = mkOption { + type = nullOr str; + description = "Email for the administrator of this host."; + default = null; + }; + + local-users = mkOption { + type = listOf str; + description = + "List of users who should have local (i.e. login) access to the host."; + default = [ ]; + }; + + description = mkOption { + type = str; + description = "Description of this host."; + default = "Another Fudo Host."; + }; + + local-admins = mkOption { + type = listOf str; + description = + "A list of users who should have admin access to this host."; + default = [ ]; + }; + + local-groups = mkOption { + type = listOf str; + description = "List of groups which should exist on this host."; + default = [ ]; + }; + + ssh-fingerprints = mkOption { + type = listOf str; + description = '' + A list of DNS SSHFP records for this host. Get with `ssh-keygen -r ` + ''; + default = [ ]; + }; + + rp = mkOption { + type = nullOr str; + description = "Responsible person."; + default = null; + }; + + tmp-on-tmpfs = mkOption { + type = bool; + description = + "Use tmpfs for /tmp. Great if you've got enough (>16G) RAM."; + default = true; + }; + + enable-gui = mkEnableOption "Install desktop GUI software."; + + docker-server = mkEnableOption "Enable Docker on the current host."; + + kerberos-services = mkOption { + type = listOf str; + description = + "List of services which should exist for this host, if it belongs to a realm."; + default = [ "ssh" "host" ]; + }; + + ssh-pubkeys = mkOption { + type = listOf path; + description = + "SSH key files of the host."; + default = []; + }; + + build-pubkeys = mkOption { + type = listOf str; + description = "SSH public keys used to access the build server."; + default = [ ]; + }; + + external-interfaces = mkOption { + type = listOf str; + description = "A list of interfaces on which to enable the firewall."; + default = [ ]; + }; + + keytab-secret-file = mkOption { + type = nullOr str; + description = "Keytab from which to create a keytab secret."; + default = null; + }; + + keep-cool = mkOption { + type = bool; + description = "A host that tends to overheat. Try to keep it cooler."; + default = false; + }; + + nixos-system = mkOption { + type = bool; + description = "Whether the host is a NixOS system."; + default = true; + }; + + arch = mkOption { + type = str; + description = "System architecture of the system."; + }; + + machine-id = mkOption { + type = nullOr str; + description = "Machine id of the system. See: man machine-id."; + default = null; + }; + + android-dev = mkEnableOption "Enable ADB on the host."; + + encrypted-filesystems = mkOption { + type = attrsOf (submodule encryptedFSOpts); + description = "List of encrypted filesystems to mount on the local host when the key is available."; + default = { }; + }; + + initrd-network = let + keypair-type = { ... }: { + options = { + public-key = mkOption { + type = str; + description = "SSH public key."; + }; + + private-key-file = mkOption { + type = str; + description = "Path to SSH private key (on the local host!)."; + }; + }; + }; + + initrd-network-config = { ... }: { + options = { + ip = mkOption { + type = str; + description = "IP to assign to the initrd image, allowing access to host during bootup."; + }; + keypair = mkOption { + type = (submodule keypair-type); + description = "SSH host key pair to use for initrd."; + }; + interface = mkOption { + type = str; + description = "Name of interface on which to listen for connections."; + }; + }; + }; + + in mkOption { + type = nullOr (submodule initrd-network-config); + description = "Configuration parameters to set up initrd SSH network."; + default = null; + }; + + backplane-password-file = mkOption { + options = path; + description = "File containing the password used by this host to connect to the backplane."; + }; + }; + }; +} diff --git a/lib/types/network-definition.nix b/lib/types/network-definition.nix new file mode 100644 index 0000000..e3b9599 --- /dev/null +++ b/lib/types/network-definition.nix @@ -0,0 +1,108 @@ +{ lib, ... }: + +with lib; +let + srvRecordOpts = { ... }: { + options = with types; { + priority = mkOption { + type = int; + description = "Priority to give to this record."; + default = 0; + }; + + weight = mkOption { + type = int; + description = + "Weight to give this record, among records of equivalent priority."; + default = 5; + }; + + port = mkOption { + type = port; + description = "Port for service on this host."; + example = 88; + }; + + host = mkOption { + type = str; + description = "Host providing service."; + example = "my-host.my-domain.com"; + }; + }; + }; + + networkHostOpts = import ./network-host.nix { inherit lib; }; + +in { + options = with types; { + hosts = mkOption { + type = attrsOf (submodule networkHostOpts); + description = "Hosts on the local network, with relevant settings."; + example = { + my-host = { + ipv4-address = "192.168.0.1"; + mac-address = "aa:aa:aa:aa:aa"; + }; + }; + default = { }; + }; + + srv-records = mkOption { + type = attrsOf (attrsOf (listOf (submodule srvRecordOpts))); + description = "SRV records for the network."; + example = { + tcp = { + kerberos = { + port = 88; + host = "krb-host.my-domain.com"; + }; + }; + }; + default = { }; + }; + + aliases = mkOption { + type = attrsOf str; + default = { }; + description = + "A mapping of host-alias -> hostnames to add to the domain record."; + example = { + mail = "my-mail-host"; + music = "musicall-host.other-domain.com."; + }; + }; + + verbatim-dns-records = mkOption { + type = listOf str; + description = "Records to be inserted verbatim into the DNS zone."; + example = [ "some-host IN CNAME base-host" ]; + default = [ ]; + }; + + dmarc-report-address = mkOption { + type = nullOr str; + description = "The email to use to recieve DMARC reports, if any."; + example = "admin-user@domain.com"; + default = null; + }; + + default-host = mkOption { + type = nullOr str; + description = + "IP of the host which will act as the default server for this domain, if any."; + default = null; + }; + + mx = mkOption { + type = listOf str; + description = "A list of mail servers serving this domain."; + default = [ ]; + }; + + gssapi-realm = mkOption { + type = nullOr str; + description = "Kerberos GSSAPI realm of the network."; + default = null; + }; + }; +} diff --git a/lib/types/network-host.nix b/lib/types/network-host.nix new file mode 100644 index 0000000..ce7518f --- /dev/null +++ b/lib/types/network-host.nix @@ -0,0 +1,32 @@ +{ lib, ... }: + +{ hostname, ... }: +with lib; +{ + options = with types; { + ipv4-address = mkOption { + type = nullOr str; + description = "The V4 IP of a given host, if any."; + default = null; + }; + + ipv6-address = mkOption { + type = nullOr str; + description = "The V6 IP of a given host, if any."; + default = null; + }; + + mac-address = mkOption { + type = nullOr types.str; + description = + "The MAC address of a given host, if desired for IP reservation."; + default = null; + }; + + description = mkOption { + type = nullOr str; + description = "Description of the host."; + default = null; + }; + }; +} diff --git a/lib/types/user.nix b/lib/types/user.nix new file mode 100644 index 0000000..e344e7b --- /dev/null +++ b/lib/types/user.nix @@ -0,0 +1,157 @@ +{ lib, ... }: + +with lib; +rec { + systemUserOpts = { name, ... }: { + options = with lib.types; { + username = mkOption { + type = str; + description = "The system user's login name."; + default = name; + }; + + description = mkOption { + type = str; + description = "Description of this system user's purpose or role"; + }; + + ldap-hashed-password = mkOption { + type = str; + description = + "LDAP-formatted hashed password for this user. Generate with slappasswd."; + }; + }; + }; + + userOpts = { name, ... }: let + username = name; + in { + options = with lib.types; { + username = mkOption { + type = str; + description = "The user's login name."; + default = username; + }; + + uid = mkOption { + type = int; + description = "Unique UID number for the user."; + }; + + common-name = mkOption { + type = str; + description = "The user's common or given name."; + }; + + primary-group = mkOption { + type = str; + description = "Primary group to which the user belongs."; + }; + + login-shell = mkOption { + type = nullOr shellPackage; + description = "The user's preferred shell."; + }; + + description = mkOption { + type = str; + default = "Fudo Member"; + description = "A description of this user's role."; + }; + + ldap-hashed-passwd = mkOption { + type = nullOr str; + description = + "LDAP-formatted hashed password, used for email and other services. Use slappasswd to generate the properly-formatted password."; + default = null; + }; + + login-hashed-passwd = mkOption { + type = nullOr str; + description = + "Hashed password for shell, used for shell access to hosts. Use mkpasswd to generate the properly-formatted password."; + default = null; + }; + + ssh-authorized-keys = mkOption { + type = listOf str; + description = "SSH public keys this user can use to log in."; + default = [ ]; + }; + + home-directory = mkOption { + type = nullOr str; + description = "Default home directory for the given user."; + default = null; + }; + + k5login = mkOption { + type = listOf str; + description = "List of Kerberos principals that map to this user."; + default = [ ]; + }; + + ssh-keys = mkOption { + type = listOf (submodule sshKeyOpts); + description = "Path to the user's public and private key files."; + default = []; + }; + + email = mkOption { + type = nullOr str; + description = "User's primary email address."; + default = null; + }; + + email-aliases = mkOption { + type = listOf str; + description = "Email aliases that should map to this user."; + default = []; + }; + }; + }; + + groupOpts = { name, ... }: { + options = with lib.types; { + group-name = mkOption { + description = "Group name."; + default = name; + }; + + description = mkOption { + type = str; + description = "Description of the group or it's purpose."; + }; + + members = mkOption { + type = listOf str; + default = [ ]; + description = "A list of users who are members of the current group."; + }; + + gid = mkOption { + type = int; + description = "GID number of the group."; + }; + }; + }; + + sshKeyOpts = { ... }: { + options = with lib.types; { + private-key = mkOption { + type = str; + description = "Path to the user's private key."; + }; + + public-key = mkOption { + type = str; + description = "Path to the user's public key."; + }; + + key-type = mkOption { + type = enum [ "rsa" "ecdsa" "ed25519" ]; + description = "Type of the user's public key."; + }; + }; + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..f0c96ba --- /dev/null +++ b/module.nix @@ -0,0 +1,7 @@ +{ config, lib, pkgs, ... }: + +{ + imports = [ + ./lib + ]; +}