summaryrefslogtreecommitdiff
path: root/sys
diff options
context:
space:
mode:
Diffstat (limited to 'sys')
-rw-r--r--sys/auth/default.nix7
-rw-r--r--sys/auth/login.nix22
-rw-r--r--sys/auth/oath.nix38
-rw-r--r--sys/auth/openssh.nix193
-rw-r--r--sys/auth/ssh-key.pub1
-rw-r--r--sys/baseline/default.nix95
-rw-r--r--sys/boot/chain.nix41
-rw-r--r--sys/boot/default.nix14
-rw-r--r--sys/boot/detached-luks.nix117
-rw-r--r--sys/boot/efi.nix49
-rw-r--r--sys/boot/firmware.nix33
-rw-r--r--sys/boot/fscrypt.nix30
-rw-r--r--sys/boot/impermanence.nix56
-rw-r--r--sys/boot/namespaced.nix33
-rw-r--r--sys/boot/secure-boot.nix51
-rw-r--r--sys/boot/stack/btrfs-toplevel-multidrive.nix99
-rw-r--r--sys/boot/stack/default.nix6
-rw-r--r--sys/boot/stack/luks-ext4-fscrypt-impermanence.nix98
-rw-r--r--sys/boot/tpm.nix128
-rw-r--r--sys/btrfs/default.nix6
-rw-r--r--sys/btrfs/mounts.nix47
-rw-r--r--sys/btrfs/snapper.nix76
-rw-r--r--sys/default.nix38
-rw-r--r--sys/env/default.nix8
-rw-r--r--sys/env/domains.nix1
-rw-r--r--sys/env/maps.nix1
-rw-r--r--sys/env/users.nix1
-rw-r--r--sys/env/virtual.nix1
-rw-r--r--sys/gitea/default.nix41
-rw-r--r--sys/hardware/altera.nix25
-rw-r--r--sys/hardware/apc.nix33
-rw-r--r--sys/hardware/athena.nix48
-rw-r--r--sys/hardware/bluetooth.nix19
-rw-r--r--sys/hardware/default.nix13
-rw-r--r--sys/hardware/epson.nix38
-rw-r--r--sys/hardware/laptop.nix19
-rw-r--r--sys/hardware/printing.nix50
-rw-r--r--sys/hardware/thinkpad.nix42
-rw-r--r--sys/hardware/yubico.nix24
-rw-r--r--sys/home-assistant/default.nix6
-rw-r--r--sys/home-assistant/hass.nix79
-rw-r--r--sys/home-assistant/yaml-extra.nix23
-rw-r--r--sys/jobs/default.nix5
-rw-r--r--sys/jobs/pki-expiry/default.nix60
-rw-r--r--sys/jobs/pki-expiry/pki-expiry.sh108
-rw-r--r--sys/kiosk/default.nix48
-rw-r--r--sys/mail/default.nix246
-rw-r--r--sys/mta/default.nix269
-rw-r--r--sys/net/default.nix9
-rw-r--r--sys/net/fail2ban.nix37
-rw-r--r--sys/net/interfaces.nix119
-rw-r--r--sys/net/nets.nix1
-rw-r--r--sys/net/options.nix278
-rw-r--r--sys/net/vsock.nix63
-rw-r--r--sys/ns/default.nix10
-rw-r--r--sys/ns/dkim/README.md1
-rw-r--r--sys/ns/mx.nix60
-rw-r--r--sys/ns/ns.nix153
-rw-r--r--sys/ns/nsd.nix87
-rw-r--r--sys/ns/ptr/default.nix9
-rw-r--r--sys/ns/ptr/gate-public-v4/default.nix14
-rw-r--r--sys/ns/ptr/gate-public-v4/serial.nix6
-rw-r--r--sys/ns/ptr/gate-public-v6/default.nix14
-rw-r--r--sys/ns/ptr/gate-public-v6/serial.nix6
-rw-r--r--sys/ns/ptr/static-prefix-v6/default.nix14
-rw-r--r--sys/ns/ptr/static-prefix-v6/serial.nix6
-rw-r--r--sys/ns/rr.nix520
-rw-r--r--sys/ns/zones/README.md1
-rw-r--r--sys/nspawn/default.nix5
-rw-r--r--sys/nspawn/dmz.nix229
-rw-r--r--sys/platform/README.md1
-rw-r--r--sys/preset/default.nix6
-rw-r--r--sys/preset/dmz.nix64
-rw-r--r--sys/preset/user.nix73
-rw-r--r--sys/seat/default.nix142
-rw-r--r--sys/syncthing/default.nix45
-rw-r--r--sys/virt/default.nix5
-rw-r--r--sys/virt/dom/README.md1
-rw-r--r--sys/virt/libvirt.nix64
-rw-r--r--sys/web/default.nix7
-rw-r--r--sys/web/nginx.nix94
-rw-r--r--sys/web/php-fpm.nix154
-rw-r--r--sys/web/sites/default.nix7
-rw-r--r--sys/web/sites/home.nix38
-rw-r--r--sys/web/sites/host.nix106
-rw-r--r--sys/web/sites/portal.nix37
86 files changed, 4972 insertions, 0 deletions
diff --git a/sys/auth/default.nix b/sys/auth/default.nix
new file mode 100644
index 0000000..ca2778a
--- /dev/null
+++ b/sys/auth/default.nix
@@ -0,0 +1,7 @@
+{
+ imports = [
+ ./login.nix
+ ./oath.nix
+ ./openssh.nix
+ ];
+}
diff --git a/sys/auth/login.nix b/sys/auth/login.nix
new file mode 100644
index 0000000..f252c1c
--- /dev/null
+++ b/sys/auth/login.nix
@@ -0,0 +1,22 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; {
+ # TODO
+ config = mkIf true {
+ security.pam = {
+ # TODO: altamente inseguro, ver problema con ~/.ssh/authorized_keys
+ # si es editado por un proceso malicioso
+ rssh = {
+ enable = true;
+
+ settings = {
+ cue = true;
+ };
+ };
+ };
+ };
+}
diff --git a/sys/auth/oath.nix b/sys/auth/oath.nix
new file mode 100644
index 0000000..6b00680
--- /dev/null
+++ b/sys/auth/oath.nix
@@ -0,0 +1,38 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.auth.oath;
+in {
+ options.local.auth.oath = {
+ enable = lib.mkEnableOption "pam-oath";
+ };
+
+ config = lib.mkIf cfg.enable {
+ security.pam = {
+ oath = {
+ digits = 6;
+ window = 30;
+
+ usersFile = "/var/trust/auth/users.oath";
+ };
+
+ services.sshd.oathAuth = true;
+ };
+
+ users.users.tunnel = {
+ uid = 1100;
+ group = "nogroup";
+ isSystemUser = true;
+
+ # Requiere oath
+ password = "tunnel";
+
+ home = "/var/empty";
+ shell = "${pkgs.coreutils}/bin/true";
+ };
+ };
+}
diff --git a/sys/auth/openssh.nix b/sys/auth/openssh.nix
new file mode 100644
index 0000000..44fb49a
--- /dev/null
+++ b/sys/auth/openssh.nix
@@ -0,0 +1,193 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.auth.openssh;
+ withOath = config.local.auth.oath.enable;
+ withPassword = config.local.auth.openssh.passwordAuthentication;
+
+ port =
+ if cfg.shiftPortNumber
+ then 2234
+ else 22;
+ restrict = cfg.restrictListen;
+
+ exemptList = optionals config.local.net.fail2ban.enable config.services.fail2ban.ignoreIP;
+in {
+ options.local.auth.openssh = {
+ enable = mkEnableOption "openssh";
+ tunnel.enable = mkEnableOption "ssh tunnel user";
+
+ #TODO: Desfasar ecdsa, inseguro
+ hostKeys = listToAttrs (map
+ (name: {
+ inherit name;
+
+ value = mkOption {
+ type = types.bool;
+ default = false;
+ };
+ }) ["ecdsa" "ed25519" "rsa"]);
+
+ restrictListen = mkOption {
+ default = null;
+
+ type = with types;
+ nullOr (submodule {
+ options = {
+ addresses = mkOption {
+ type = listOf str;
+ };
+
+ interface = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ vsockCid = mkOption {
+ type = nullOr ints.u32;
+ default = null;
+ };
+ };
+ });
+ };
+
+ passwordAuthentication = mkOption {
+ type = types.bool;
+ default = false;
+ };
+
+ shiftPortNumber = mkOption {
+ type = types.bool;
+ default = true;
+ };
+
+ withDeployKeys = mkOption {
+ type = types.bool;
+ default = false;
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.tunnel.enable -> withOath;
+ message = "SSH tunnel requires oath";
+ }
+ {
+ assertion = restrict != null -> (restrict.vsockCid != null -> (restrict.interface == null && restrict.addresses == []));
+ message = "SSH vsock restrict requires disabling inet";
+ }
+ {
+ assertion = restrict != null -> (restrict.vsockCid != null -> config.services.openssh.startWhenNeeded);
+ message = "SSH vsock restrict requires socket activation";
+ }
+ {
+ assertion = restrict != null -> (restrict.vsockCid != null -> config.local.virt.enable);
+ message = "SSH vsock restrict requires nixvirt";
+ }
+ {
+ assertion = any (key: key) (attrValues cfg.hostKeys);
+ message = "No OpenSSH host keys were enabled";
+ }
+ ];
+
+ local.boot.impermanence.files =
+ flatten (map (key: [key.path "${key.path}.pub"]) config.services.openssh.hostKeys);
+
+ networking.firewall = {
+ interfaces = optionalAttrs (restrict != null && restrict.interface != null) {
+ ${restrict.interface}.allowedTCPPorts = [port];
+ };
+
+ allowedTCPPorts = optional (restrict == null || restrict.interface == null) port;
+ };
+
+ services.openssh = {
+ enable = true;
+
+ ports = optional (restrict != null -> restrict.addresses != []) port;
+ startWhenNeeded = mkDefault (!config.services.fail2ban.enable);
+
+ extraConfig =
+ optionalString (exemptList != []) ''
+ PerSourcePenaltyExemptList ${concatStringsSep "," exemptList}
+ ''
+ + optionalString cfg.tunnel.enable ''
+ # User 'tunnel' has no password. Use PAM OATH
+ # and connect with -N, forward with -R.
+ Match User tunnel
+ AllowTcpForwarding remote
+ AllowStreamLocalForwarding no
+ X11Forwarding no
+ PermitTunnel no
+ GatewayPorts no
+ AllowAgentForwarding no
+ PermitOpen none
+ PermitListen 60220 60221 60222 60223 60224 60225 60226 60227 60228 60229
+
+ Banner ${pkgs.writeText "tunnel-banner" ''
+ This is a reverse tunnel
+ ''}
+ '';
+
+ hostKeys =
+ map
+ (name:
+ {
+ path = "/etc/ssh/ssh_host_${name}_key";
+ type = name;
+ }
+ // optionalAttrs (name == "rsa") {
+ bits = 4096;
+ })
+ (attrNames (filterAttrs (name: enable: enable) cfg.hostKeys));
+
+ settings = {
+ X11Forwarding = config.local.seat.enable && config.local.seat.graphical;
+ PermitRootLogin = "prohibit-password";
+ PasswordAuthentication = withOath || withPassword; # Necesario para oath, no reemplaza a oath
+ };
+
+ listenAddresses =
+ mkIf (restrict != null)
+ (map (addr: {inherit addr;}) restrict.addresses);
+ };
+
+ systemd.sockets = mkIf (restrict != null && restrict.vsockCid != null) {
+ sshd = let
+ kernelMod = "modprobe@${
+ if restrict.vsockCid == 2
+ then "vhost_"
+ else ""
+ }vsock.service";
+ in {
+ after = [kernelMod];
+ wants = [kernelMod];
+
+ socketConfig.ListenStream = mkForce ["vsock:${toString restrict.vsockCid}:${toString port}"];
+ };
+ };
+
+ users.users = {
+ root = mkIf cfg.withDeployKeys {
+ openssh.authorizedKeys.keyFiles = [./ssh-key.pub];
+ };
+
+ tunnel = mkIf cfg.tunnel.enable {
+ uid = 1100;
+ group = "nogroup";
+ isSystemUser = true;
+
+ # Requiere oath
+ password = "tunnel";
+
+ home = "/var/empty";
+ shell = "${pkgs.coreutils}/bin/true";
+ };
+ };
+ };
+}
diff --git a/sys/auth/ssh-key.pub b/sys/auth/ssh-key.pub
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/auth/ssh-key.pub
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/baseline/default.nix b/sys/baseline/default.nix
new file mode 100644
index 0000000..96654d8
--- /dev/null
+++ b/sys/baseline/default.nix
@@ -0,0 +1,95 @@
+{
+ config,
+ flakes,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; {
+ config = {
+ # This value determines the NixOS release from which the default
+ # settings for stateful data, like file locations and database versions
+ # on your system were taken. It‘s perfectly fine and recommended to leave
+ # this value at the release version of the first install of this system.
+ # Before changing this value read the documentation for this option
+ # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+ system.stateVersion = "21.11"; # Did you read the comment?
+
+ environment = {
+ pathsToLink = ["/share/zsh"];
+
+ systemPackages = with pkgs;
+ [
+ git
+ ]
+ ++ optionals (!config.boot.isContainer) [
+ lm_sensors
+ lshw
+ parted
+ pciutils
+ smartmontools
+ usbutils
+ ];
+ };
+
+ home-manager = {
+ useGlobalPkgs = true;
+ useUserPackages = true;
+
+ extraSpecialArgs = {inherit flakes;};
+ };
+
+ lib.local = pkgs.local.lib;
+
+ local.boot.impermanence.directories = ["/var/lib/dhparams"];
+
+ nix = {
+ package = pkgs.nix;
+
+ channel.enable = false;
+
+ extraOptions = ''
+ experimental-features = nix-command flakes
+ '';
+
+ gc = {
+ dates = "Sat *-*-* 00:00:00";
+ automatic = true;
+ };
+
+ # No me interesa el global registry
+ settings.flake-registry = "";
+ };
+
+ programs = {
+ fuse.userAllowOther = true;
+ zsh.enable = true;
+ };
+
+ security.dhparams = {
+ enable = true;
+ defaultBitSize = 4096;
+ };
+
+ services = {
+ earlyoom = {
+ enable = mkDefault true;
+ enableNotifications = true;
+ };
+
+ journald.extraConfig = ''
+ ForwardToKMsg=no
+ ForwardToWall=no
+ ForwardToConsole=no
+ '';
+ };
+
+ # Coredumps son un riesgo de seguridad y puden usar mucho disco
+ systemd.coredump.extraConfig = ''
+ Storage=none
+ ProcessSizeMax=0
+ '';
+
+ time.timeZone = mkDefault "America/Costa_Rica";
+ };
+}
diff --git a/sys/boot/chain.nix b/sys/boot/chain.nix
new file mode 100644
index 0000000..43edcb4
--- /dev/null
+++ b/sys/boot/chain.nix
@@ -0,0 +1,41 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot;
+in {
+ options.local.boot = {
+ loader = mkOption {
+ type = types.enum ["none" "grub" "systemd-boot"];
+ };
+
+ kernel = mkOption {
+ type = types.raw;
+ };
+ };
+
+ config = mkIf (cfg.loader != "none") {
+ boot = {
+ kernelPackages = cfg.kernel;
+
+ loader =
+ if cfg.loader == "grub"
+ then {
+ grub = {
+ enable = true;
+ device = "nodev";
+ efiSupport = true;
+ };
+ }
+ else {
+ systemd-boot = {
+ enable = true;
+ editor = true;
+ };
+ };
+ };
+ };
+}
diff --git a/sys/boot/default.nix b/sys/boot/default.nix
new file mode 100644
index 0000000..4580cba
--- /dev/null
+++ b/sys/boot/default.nix
@@ -0,0 +1,14 @@
+{
+ imports = [
+ ./chain.nix
+ ./detached-luks.nix
+ ./efi.nix
+ ./firmware.nix
+ ./fscrypt.nix
+ ./impermanence.nix
+ ./namespaced.nix
+ ./secure-boot.nix
+ ./stack
+ ./tpm.nix
+ ];
+}
diff --git a/sys/boot/detached-luks.nix b/sys/boot/detached-luks.nix
new file mode 100644
index 0000000..78ae35c
--- /dev/null
+++ b/sys/boot/detached-luks.nix
@@ -0,0 +1,117 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.detachedLuks;
+
+ bootFs = config.fileSystems."/boot";
+ tpmInitrd = config.local.boot.tpm.initrd.enable;
+
+ pcrList = concatStringsSep "," (map toString config.local.boot.tpm.initrd.pcrs);
+in {
+ options.local.boot.detachedLuks = {
+ enable = mkEnableOption "detached LUKS header in initrd";
+
+ headerFromBoot = mkOption {
+ type = types.str;
+ };
+
+ tpmStorageFromBoot = mkOption {
+ type = types.str;
+ default = "tpm-boot";
+ };
+
+ crypt = mkOption {
+ type = types.str;
+ };
+
+ target = mkOption {
+ type = types.str;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ boot.initrd = let
+ headerPath = "/initrd-boot/${cfg.headerFromBoot}";
+ headerPathEscaped = escapeShellArg headerPath;
+
+ tpmPath = escapeShellArg "/initrd-boot/${cfg.tpmStorageFromBoot}";
+ hardwareKeyPath = "/tpm/unsealed.luks-key";
+ in {
+ preDeviceCommands = ''
+ mkdir -p `dirname ${headerPathEscaped}`
+ touch ${headerPathEscaped}
+ '';
+
+ postDeviceCommands = mkIf (!config.boot.initrd.systemd.enable) ''
+ # Set the system time from the hardware clock to work around a
+ # bug in qemu-kvm > 1.5.2 (where the VM clock is initialised
+ # to the *boot time* of the host).
+ hwclock -s
+ '';
+
+ #FIXME: Demasiado vulgar
+ preLVMCommands = optionalString (config.local.boot.efi.enable && config.local.boot.efi.removable) ''
+ sleep 2
+ '';
+
+ luks.devices.${cfg.target} = {
+ device = cfg.crypt;
+ header = headerPath;
+ preLVM = false;
+
+ keyFile = mkIf tpmInitrd hardwareKeyPath;
+ fallbackToPassword = tpmInitrd;
+
+ preOpenCommands =
+ ''
+ mount -o ro -t ${bootFs.fsType} ${bootFs.device} /initrd-boot
+ ''
+ + optionalString tpmInitrd ''
+ mkdir /tpm
+ touch ${escapeShellArg hardwareKeyPath}
+
+ unseal_tpm_key() {
+ tpm2 createprimary -Q -C owner -g sha256 -G ecc -c /tpm/prim.ctx || return
+
+ tpm2 loadexternal -Q -C owner -G rsa -u ${tpmPath}/signing-key.pub -c /tpm/signing-key.ctx -n /tpm/signing-key.name || return
+ tpm2 verifysignature -Q -c /tpm/signing-key.ctx -g sha256 -m ${tpmPath}/auth.policy -s ${tpmPath}/auth.sig -t /tpm/verified.ticket -f rsassa || return
+
+ tpm2 startauthsession -Q -S /tpm/session.ctx --policy-session || return
+
+ tpm_resets=`tpm2 readclock | grep reset_count | sed 's/.*: //g'`
+ tpm2 policycountertimer -Q -S /tpm/session.ctx resets="$tpm_resets" || return
+ tpm2 policypcr -Q -S /tpm/session.ctx -l sha256:${pcrList} || return
+ tpm2 policyauthorize -Q -S /tpm/session.ctx -i ${tpmPath}/auth.policy -n /tpm/signing-key.name -t /tpm/verified.ticket || return
+
+ tpm2 load -Q -C /tpm/prim.ctx -u ${tpmPath}/key.pub -r ${tpmPath}/key.priv -c /tpm/key.ctx || return
+ tpm2 unseal -Q -c /tpm/key.ctx -p session:/tpm/session.ctx -o ${escapeShellArg hardwareKeyPath} || return
+
+ tpm2 flushcontext /tpm/session.ctx
+ }
+
+ unseal_tpm_key
+ '';
+
+ postOpenCommands = mkBefore (''
+ umount /initrd-boot
+ ''
+ + optionalString tpmInitrd ''
+ rm -r /tpm
+ '');
+ };
+ };
+
+ local.boot = {
+ stack = {
+ btrfsToplevelMultidrive.toplevel.device = "/dev/mapper/${cfg.target}";
+ luksExt4FscryptImpermanence = {inherit (cfg) target;};
+ };
+
+ tpm.initrd.enable = mkDefault config.local.boot.tpm.enable;
+ };
+ };
+}
diff --git a/sys/boot/efi.nix b/sys/boot/efi.nix
new file mode 100644
index 0000000..71c42c8
--- /dev/null
+++ b/sys/boot/efi.nix
@@ -0,0 +1,49 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.efi;
+in {
+ options.local.boot.efi = {
+ enable = mkEnableOption "EFI with FAT32 system partition";
+
+ esp = {
+ mountpoint = mkOption {
+ type = types.enum ["/boot" "/boot/efi"];
+ default = "/boot";
+ };
+
+ uuid = mkOption {
+ type = types.strMatching "[0-9A-F]{4}-[0-9A-F]{4}";
+ };
+ };
+
+ removable = mkOption {
+ type = types.bool;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ boot = {
+ initrd.supportedFilesystems = ["vfat"];
+
+ loader = {
+ efi = {
+ efiSysMountPoint = cfg.esp.mountpoint;
+ canTouchEfiVariables = !cfg.removable;
+ };
+
+ grub.efiInstallAsRemovable = cfg.removable;
+ };
+ };
+
+ fileSystems.${cfg.esp.mountpoint} = {
+ device = "/dev/disk/by-uuid/${cfg.esp.uuid}";
+ fsType = "vfat";
+ options = ["noatime" "umask=027" "sync"];
+ neededForBoot = true;
+ };
+ };
+}
diff --git a/sys/boot/firmware.nix b/sys/boot/firmware.nix
new file mode 100644
index 0000000..b3598a7
--- /dev/null
+++ b/sys/boot/firmware.nix
@@ -0,0 +1,33 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.firmware;
+in {
+ options.local.boot.firmware = {
+ mode = mkOption {
+ type = types.enum ["none" "redistributable" "all"];
+ };
+
+ cpuVendor = mkOption {
+ type = types.enum ["amd" "intel"];
+ };
+ };
+
+ config = mkIf (cfg.mode != "none") {
+ hardware = {
+ cpu = {
+ amd.updateMicrocode = cfg.cpuVendor == "amd";
+ intel.updateMicrocode = cfg.cpuVendor == "intel";
+ };
+
+ enableAllFirmware = cfg.mode == "all";
+ enableRedistributableFirmware = true;
+ };
+
+ services.fwupd.enable = true;
+ };
+}
diff --git a/sys/boot/fscrypt.nix b/sys/boot/fscrypt.nix
new file mode 100644
index 0000000..459e02b
--- /dev/null
+++ b/sys/boot/fscrypt.nix
@@ -0,0 +1,30 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.fscrypt;
+in {
+ options.local.boot.fscrypt = {
+ enable = mkEnableOption "fscrypt support";
+ };
+
+ config = mkIf cfg.enable {
+ environment.systemPackages = [pkgs.fscrypt-experimental];
+
+ local.boot.impermanence = {
+ directories = [
+ {
+ directory = "/.fscrypt";
+ mode = "u=rwx,g=rx,o=rx";
+ }
+ ];
+
+ files = [
+ "/etc/fscrypt.conf"
+ ];
+ };
+ };
+}
diff --git a/sys/boot/impermanence.nix b/sys/boot/impermanence.nix
new file mode 100644
index 0000000..632094b
--- /dev/null
+++ b/sys/boot/impermanence.nix
@@ -0,0 +1,56 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.impermanence;
+in {
+ options.local.boot.impermanence = {
+ enable = mkEnableOption "root fs impermanence";
+
+ #TODO: type correcto de files, directories?
+
+ directories = mkOption {
+ type = with lib.types; listOf (either str attrs);
+ default = [];
+ };
+
+ files = mkOption {
+ type = with lib.types; listOf (either str attrs);
+ default = [];
+ };
+ };
+
+ config = mkMerge [
+ {
+ local.boot.impermanence = {
+ directories = [
+ "/etc/lvm"
+ "/var/lib/nixos"
+ "/var/log"
+ ];
+
+ files = [
+ "/etc/machine-id"
+ "/var/lib/logrotate.status"
+ ];
+ };
+ }
+ (mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = (config.fileSystems ? "/persist") && config.fileSystems."/persist".neededForBoot;
+ message = "Impermanence requires /persist to be a neededForBoot mountpoint";
+ }
+ ];
+
+ environment.persistence."/persist" = {
+ hideMounts = true;
+
+ files = cfg.files;
+ directories = cfg.directories;
+ };
+ })
+ ];
+}
diff --git a/sys/boot/namespaced.nix b/sys/boot/namespaced.nix
new file mode 100644
index 0000000..3f95960
--- /dev/null
+++ b/sys/boot/namespaced.nix
@@ -0,0 +1,33 @@
+{
+ config,
+ lib,
+ options,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.namespaced;
+in {
+ options.local.boot.namespaced = {
+ enable = mkEnableOption "system containerization";
+ };
+
+ config = mkIf cfg.enable {
+ boot.isContainer = true;
+
+ local.boot = mkMerge ([
+ {
+ loader = mkForce "none";
+
+ efi.enable = mkForce false;
+ firmware.mode = mkForce "none";
+ secureBoot.enable = mkForce false;
+ impermanence.enable = mkForce false;
+ }
+ ]
+ ++ map
+ (name: {
+ stack.${name}.enable = mkForce false;
+ })
+ (attrNames options.local.boot.stack));
+ };
+}
diff --git a/sys/boot/secure-boot.nix b/sys/boot/secure-boot.nix
new file mode 100644
index 0000000..b13ab7c
--- /dev/null
+++ b/sys/boot/secure-boot.nix
@@ -0,0 +1,51 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.secureBoot;
+
+ pkiBundle =
+ if cfg.legacyPath
+ then "/etc/secureboot"
+ else "/var/lib/sbctl";
+in {
+ options.local.boot.secureBoot = {
+ enable = mkEnableOption "secure boot";
+
+ legacyPath = mkOption {
+ type = types.bool;
+ default = false;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = config.local.boot.efi.enable;
+ message = "secure boot requires EFI";
+ }
+ {
+ assertion = config.local.boot.loader == "systemd-boot";
+ message = "lanzaboote requires systemd-boot";
+ }
+ ];
+
+ boot = {
+ loader.systemd-boot.enable = mkForce false;
+
+ lanzaboote = {
+ enable = true;
+ inherit pkiBundle;
+ };
+ };
+
+ environment.systemPackages = [
+ pkgs.sbctl
+ ];
+
+ local.boot.impermanence.directories = [pkiBundle];
+ };
+}
diff --git a/sys/boot/stack/btrfs-toplevel-multidrive.nix b/sys/boot/stack/btrfs-toplevel-multidrive.nix
new file mode 100644
index 0000000..52db865
--- /dev/null
+++ b/sys/boot/stack/btrfs-toplevel-multidrive.nix
@@ -0,0 +1,99 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.stack.btrfsToplevelMultidrive;
+in {
+ options.local.boot.stack.btrfsToplevelMultidrive = {
+ enable = mkEnableOption "filesystem stack: persistent btrfs toplevel with optional hdd drive";
+
+ toplevel = {
+ device = mkOption {
+ type = types.str;
+ };
+
+ ssd = mkOption {
+ type = types.bool;
+ };
+
+ snapshot = mkOption {
+ type = types.bool;
+ default = false;
+ };
+
+ root = mkOption {
+ type = types.str;
+ };
+
+ pivot = mkOption {
+ type = types.str;
+ default = "/";
+ };
+ };
+
+ secondary = {
+ device = mkOption {
+ type = types.str;
+ };
+
+ ssd = mkOption {
+ type = types.bool;
+ };
+
+ snapshot = mkOption {
+ type = types.bool;
+ default = false;
+ };
+
+ home = mkOption {
+ type = types.str;
+ };
+
+ pivot = mkOption {
+ type = types.str;
+ default = "/";
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ local.btrfs = {
+ mounts = {
+ "/" = {
+ inherit (cfg.toplevel) device ssd;
+ subvol = cfg.toplevel.root;
+ };
+
+ "/toplevel" = {
+ inherit (cfg.toplevel) device ssd;
+ subvol = cfg.toplevel.pivot;
+ };
+
+ #FIXME: Este nombre es legacy
+ "/hdd" = {
+ inherit (cfg.secondary) device ssd;
+ subvol = cfg.secondary.pivot;
+ };
+
+ "/home" = {
+ inherit (cfg.secondary) device ssd;
+ subvol = cfg.secondary.home;
+ };
+ };
+
+ snapper =
+ optionalAttrs cfg.toplevel.snapshot
+ {
+ root = "/";
+ }
+ // optionalAttrs cfg.secondary.snapshot {
+ home = "/home";
+ };
+ };
+
+ # Asegura que /hdd sea descifrado antes de intentar montar /home
+ fileSystems."/home".depends = ["/hdd"];
+ };
+}
diff --git a/sys/boot/stack/default.nix b/sys/boot/stack/default.nix
new file mode 100644
index 0000000..ff211e6
--- /dev/null
+++ b/sys/boot/stack/default.nix
@@ -0,0 +1,6 @@
+{
+ imports = [
+ ./btrfs-toplevel-multidrive.nix
+ ./luks-ext4-fscrypt-impermanence.nix
+ ];
+}
diff --git a/sys/boot/stack/luks-ext4-fscrypt-impermanence.nix b/sys/boot/stack/luks-ext4-fscrypt-impermanence.nix
new file mode 100644
index 0000000..81feb60
--- /dev/null
+++ b/sys/boot/stack/luks-ext4-fscrypt-impermanence.nix
@@ -0,0 +1,98 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.stack.luksExt4FscryptImpermanence;
+in {
+ options.local.boot.stack.luksExt4FscryptImpermanence = {
+ enable = mkEnableOption "filesystem stack: whatever LUKS approach+ext4+impermanence with per-boot keys";
+
+ target = mkOption {
+ type = types.str;
+ };
+ };
+
+ # - boot device
+ # - some unknown fs, probably vfat
+ # - detached luks header file
+ #
+ # - toplevel device
+ # - headerless luks
+ # - /toplevel (ext4)
+ # - /toplevel/nix
+ # - /toplevel/persist
+ # - /toplevel/boot-archive.pub
+ # - /toplevel/boot-keys
+ # - /toplevel/boot-keys/2000-01-01T00:00:00-06:00.key.crypt (encrypted for /toplevel/boot-archive.pub)
+ # - /toplevel/boot-keys/...
+ # - /toplevel/boot-keys/last.key.crypt -> 2000-01-01T00:00:00-06:00.key.crypt
+ # - /toplevel/boots
+ # - /toplevel/boots/2000-01-01T00:00:00-06:00 (raw protector in last.key.crypt)
+ # - /toplevel/boots/...
+ # - /toplevel/boots/last -> 2000-01-01T00:00:00-06:00 (mounted as /)
+ config = mkIf cfg.enable {
+ boot.initrd.luks.devices.${cfg.target}.postOpenCommands = let
+ fscryptctl = "${pkgs.fscryptctl}/bin/fscryptctl";
+ in ''
+ # FIXME: posiblemente algunos --make-* son innecesarios a partir de aquí
+ mkdir -p /mnt-root /mnt-toplevel
+ mount -o noatime /dev/mapper/${cfg.target} /mnt-toplevel
+ mount --make-private /mnt-toplevel
+
+ boot_stamp="$(date -Is)"
+ root_from_toplevel="/mnt-toplevel/boots/$boot_stamp"
+
+ mkdir -p "$root_from_toplevel" /mnt-toplevel/boot-keys
+ chmod 700 /mnt-toplevel/boot-keys
+
+ head -c64 /dev/urandom >/boot-key
+ key_id=$(${fscryptctl} add_key /mnt-toplevel </boot-key)
+ ${fscryptctl} set_policy "$key_id" "$root_from_toplevel"
+ (umask 077; test -f /mnt-toplevel/boot-archive.pub && \
+ ${pkgs.openssl}/bin/openssl pkeyutl -encrypt \
+ -in /boot-key -pubin -inkey /mnt-toplevel/boot-archive.pub \
+ -out "/mnt-toplevel/boot-keys/$boot_stamp.key.crypt")
+ rm -f /boot-key
+
+ ln -Tsf "$boot_stamp" /mnt-toplevel/boots/last
+ ln -Tsf "$boot_stamp.key.crypt" /mnt-toplevel/boot-keys/last.key.crypt
+
+ mount --bind "$root_from_toplevel" /mnt-root
+ mount --make-shared /mnt-root
+
+ # mount --move es mala idea, ya que "moving a mount residing under a
+ # shared mount is unsupported"
+ mkdir -p /mnt-root/toplevel
+ mount --bind /mnt-toplevel /mnt-root/toplevel
+ mount --make-private /mnt-root/toplevel
+ umount /mnt-toplevel
+ '';
+
+ fileSystems = {
+ "/" = {
+ device = "none";
+ fsType = "ext4";
+ options = ["remount"];
+ };
+
+ "/nix" = {
+ device = "/persist/nix";
+ options = ["bind"];
+ };
+
+ "/persist" = {
+ device = "/toplevel/persist";
+ options = ["bind"];
+ neededForBoot = true;
+ };
+ };
+
+ local.boot = {
+ fscrypt.enable = true;
+ impermanence.enable = true;
+ };
+ };
+}
diff --git a/sys/boot/tpm.nix b/sys/boot/tpm.nix
new file mode 100644
index 0000000..ecc115b
--- /dev/null
+++ b/sys/boot/tpm.nix
@@ -0,0 +1,128 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.boot.tpm;
+
+ pcrList = concatStringsSep "," (map toString cfg.initrd.pcrs);
+
+ # Crear signing-key con:
+ # $ openssl genrsa -out ~/vtmp/signing-key.priv 2048
+ # $ openssl rsa -in ~/vtmp/signing-key.priv -out ~/vtmp/signing-key.pub -pubout
+ # Y copiar signing-key.pub a /boot/tpm-boot. Guardar signing-key.priv en lugar seguro.
+ #
+ # Crear llave con:
+ # $ tpm2_loadexternal -G rsa -C owner -u signing-key.pub -c signing-key.ctx -n signing-key.name
+ # $ tpm2_startauthsession -S session.ctx
+ # $ tpm2_policyauthorize -S session.ctx -L require-signed.policy -n signing-key.name
+ # $ tpm2_flushcontext session.ctx
+ # $ tpm2_createprimary -C owner -g sha256 -G ecc -c prim.ctx
+ # $ head -c128 /dev/urandom | tpm2_create -C prim.ctx -u key.pub -r key.priv -c key.ctx -L require-signed.policy -i-
+ # Y mover key.priv/key.pub a /boot/tpm-boot
+ #
+ # Usage: tpm2-grant-next-boot < signing-key.priv
+ # Genera auth.policy y auth.sig
+ tpm2-grant-next-boot = pkgs.writeShellApplication {
+ name = "tpm2-grant-next-boot";
+
+ runtimeInputs = [
+ pkgs.jq
+ pkgs.openssl
+ pkgs.sbctl
+ pkgs.tpm2-tools
+ ];
+
+ text = ''
+ if [ -z "''${YES_I_DO_WANT_TO_SIGN_WITH_SECURE_BOOT_DISABLED:=}" ] && [ "$(sbctl status --json | jq .secure_boot)" != "truee" ]; then
+ echo "$0: bad Secure Boot state, check the output of \`sbctl status\`" >&2
+ echo "$0: signing a TPM PCR policy with Secure Boot disabled is dangerous" >&2
+ echo "$0: set 'YES_I_DO_WANT_TO_SIGN_WITH_SECURE_BOOT_DISABLED' to skip this check" >&2
+ exit 1
+ fi
+
+ ctx_dir="$(mktemp -d)"
+ trap 'rm -rf -- "$ctx_dir"' EXIT
+
+ tpm2_createprimary -Q -C owner -g sha256 -G ecc -c "$ctx_dir/prim.ctx"
+
+ tpm2_startauthsession -Q -S "$ctx_dir/session.ctx"
+ tpm_resets=$(tpm2_readclock | grep reset_count | sed 's/.*: //')
+ tpm2_policycountertimer -Q -S "$ctx_dir/session.ctx" resets="$((tpm_resets+1))"
+ tpm2_policypcr -Q -S "$ctx_dir/session.ctx" -L auth.policy -l sha256:${pcrList}
+ tpm2_flushcontext -Q "$ctx_dir/session.ctx"
+
+ openssl dgst -sha256 -sign /dev/stdin -out auth.sig auth.policy
+ '';
+ };
+in {
+ options.local.boot.tpm = {
+ enable = mkEnableOption "Trusted Platform Module 2.0";
+
+ driver = mkOption {
+ type = types.enum ["tis" "crb"];
+ };
+
+ initrd = {
+ enable = mkEnableOption "TPM2 in initrd";
+
+ pcrs = mkOption {
+ type = with types; listOf (ints.between 0 23);
+
+ # From 'systemd-analyze pcrs'
+ default = [
+ 0 # platform-code
+ 1 # platform-config
+ 2 # external-code
+ 3 # external-config
+ 4 # boot-loader-code
+ 5 # boot-loader-config
+ 7 # secure-boot-policy
+ 9 # kernel-initrd
+ 11 # kernel-boot
+ 12 # kernel-config
+ 13 # sysexts
+ 14 # shim-policy
+ ];
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = config.local.boot.efi.enable;
+ message = "TPM2 requires EFI";
+ }
+ {
+ assertion = cfg.initrd.enable -> cfg.enable;
+ message = "TPM2 in initrd requires TPM2";
+ }
+ ];
+
+ boot.initrd = mkIf cfg.initrd.enable {
+ extraUtilsCommands = ''
+ copy_bin_and_libs ${pkgs.tpm2-tools}/bin/.tpm2-wrapped
+ mv $out/bin/{.tpm2-wrapped,tpm2}
+ cp {${pkgs.tpm2-tss},$out}/lib/libtss2-tcti-device.so.0
+ '';
+
+ kernelModules = [
+ "tpm_${cfg.driver}"
+ ];
+ };
+
+ environment.systemPackages = optionals cfg.initrd.enable [
+ tpm2-grant-next-boot
+ ];
+
+ security.tpm2 = {
+ enable = true;
+
+ pkcs11.enable = true;
+ tctiEnvironment.enable = true;
+ };
+ };
+}
diff --git a/sys/btrfs/default.nix b/sys/btrfs/default.nix
new file mode 100644
index 0000000..ee76537
--- /dev/null
+++ b/sys/btrfs/default.nix
@@ -0,0 +1,6 @@
+{
+ imports = [
+ ./mounts.nix
+ ./snapper.nix
+ ];
+}
diff --git a/sys/btrfs/mounts.nix b/sys/btrfs/mounts.nix
new file mode 100644
index 0000000..3863356
--- /dev/null
+++ b/sys/btrfs/mounts.nix
@@ -0,0 +1,47 @@
+{
+ lib,
+ config,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.btrfs;
+in {
+ options.local.btrfs = {
+ mounts = mkOption {
+ default = {};
+
+ type = with lib.types;
+ attrsOf (submodule {
+ options = {
+ ssd = mkOption {
+ type = bool;
+ };
+
+ device = mkOption {
+ type = str;
+ };
+
+ subvol = mkOption {
+ type = str;
+ };
+ };
+ });
+ };
+ };
+
+ config = mkIf (cfg.mounts != {}) {
+ fileSystems = let
+ btrfsMount = {
+ device,
+ subvol,
+ ssd,
+ }: {
+ inherit device;
+ fsType = "btrfs";
+ options = ["noatime" "compress=zstd" "subvol=${subvol}"] ++ optional ssd "ssd";
+ };
+ in
+ mapAttrs (_: btrfsMount) cfg.mounts;
+ };
+}
diff --git a/sys/btrfs/snapper.nix b/sys/btrfs/snapper.nix
new file mode 100644
index 0000000..2d29aa4
--- /dev/null
+++ b/sys/btrfs/snapper.nix
@@ -0,0 +1,76 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.btrfs;
+in {
+ options.local.btrfs = {
+ snapper = mkOption {
+ type = with lib.types; attrsOf str;
+ default = {};
+ };
+ };
+
+ config = mkIf (cfg.snapper != {}) {
+ environment.systemPackages = [pkgs.local.btclone];
+
+ services.snapper.configs = let
+ snapperConfig = _: subvolume: {
+ SUBVOLUME = subvolume;
+
+ # btrfs qgroup for space aware cleanup algorithms
+ QGROUP = "";
+
+ # fraction of the filesystems space the snapshots may use
+ SPACE_LIMIT = "0.5";
+
+ # fraction of the filesystems space that should be free
+ FREE_LIMIT = "0.2";
+
+ # users and groups allowed to work with config
+ ALLOW_USERS = [];
+ ALLOW_GROUPS = [];
+
+ # sync users and groups from ALLOW_USERS and ALLOW_GROUPS to .snapshots
+ # directory
+ SYNC_ACL = "no";
+
+ # start comparing pre- and post-snapshot in background after creating
+ # post-snapshot
+ BACKGROUND_COMPARISON = "yes";
+
+ # run daily number cleanup
+ NUMBER_CLEANUP = "yes";
+
+ # limit for number cleanup
+ NUMBER_MIN_AGE = "1800";
+ NUMBER_LIMIT = "100";
+ NUMBER_LIMIT_IMPORTANT = "10";
+
+ # create hourly snapshots
+ TIMELINE_CREATE = true;
+
+ # cleanup hourly snapshots after some time
+ TIMELINE_CLEANUP = true;
+
+ # limits for timeline cleanup
+ TIMELINE_MIN_AGE = "1800";
+ TIMELINE_LIMIT_HOURLY = "24";
+ TIMELINE_LIMIT_DAILY = "7";
+ TIMELINE_LIMIT_WEEKLY = "4";
+ TIMELINE_LIMIT_MONTHLY = "12";
+ TIMELINE_LIMIT_YEARLY = "10";
+
+ # cleanup empty pre-post-pairs
+ EMPTY_PRE_POST_CLEANUP = "yes";
+
+ # limits for empty pre-post-pair cleanup
+ EMPTY_PRE_POST_MIN_AGE = "1800";
+ };
+ in
+ mapAttrs snapperConfig cfg.snapper;
+ };
+}
diff --git a/sys/default.nix b/sys/default.nix
new file mode 100644
index 0000000..131ddeb
--- /dev/null
+++ b/sys/default.nix
@@ -0,0 +1,38 @@
+{
+ lib,
+ config,
+ flakes,
+ pkgs,
+ ...
+}:
+with lib; {
+ imports = [
+ flakes.nixpkgs.nixosModules.notDetected
+ flakes.nixvirt.nixosModules.default
+ flakes.lanzaboote.nixosModules.lanzaboote
+ flakes.impermanence.nixosModule
+ flakes.home-manager.nixosModules.home-manager
+ flakes.trivionomicon.nixosModules.default
+ ../pki
+ ./auth
+ ./baseline
+ ./boot
+ ./btrfs
+ ./env
+ ./gitea
+ ./hardware
+ ./home-assistant
+ ./jobs
+ ./kiosk
+ ./mail
+ ./mta
+ ./net
+ ./ns
+ ./nspawn
+ ./preset
+ ./seat
+ ./syncthing
+ ./virt
+ ./web
+ ];
+}
diff --git a/sys/env/default.nix b/sys/env/default.nix
new file mode 100644
index 0000000..5e3cb98
--- /dev/null
+++ b/sys/env/default.nix
@@ -0,0 +1,8 @@
+{
+ imports = [
+ ./maps.nix
+ ./domains.nix
+ ./users.nix
+ ./virtual.nix
+ ];
+}
diff --git a/sys/env/domains.nix b/sys/env/domains.nix
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/env/domains.nix
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/env/maps.nix b/sys/env/maps.nix
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/env/maps.nix
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/env/users.nix b/sys/env/users.nix
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/env/users.nix
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/env/virtual.nix b/sys/env/virtual.nix
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/env/virtual.nix
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/gitea/default.nix b/sys/gitea/default.nix
new file mode 100644
index 0000000..212b9f1
--- /dev/null
+++ b/sys/gitea/default.nix
@@ -0,0 +1,41 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.gitea;
+in {
+ options.local.gitea = {
+ enable = mkEnableOption "gitea";
+ };
+
+ config = mkIf cfg.enable {
+ environment.etc."fail2ban/filter.d/gitea.local".text = ''
+ [Definition]
+ failregex = .*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from <HOST>
+ ignoreregex =
+ '';
+
+ services = {
+ fail2ban.jails.gitea.settings = {
+ filter = "gitea";
+ logpath = "${config.services.gitea.stateDir}/log/gitea.log";
+ maxretry = "10";
+ findtime = "3600";
+ bantime = "900";
+ action = "iptables-allports";
+ };
+
+ gitea = {
+ enable = true;
+ useWizard = true;
+ };
+ };
+
+ users = {
+ users.gitea.uid = 962;
+ groups.gitea.gid = 962;
+ };
+ };
+}
diff --git a/sys/hardware/altera.nix b/sys/hardware/altera.nix
new file mode 100644
index 0000000..fddd722
--- /dev/null
+++ b/sys/hardware/altera.nix
@@ -0,0 +1,25 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.altera;
+in {
+ options.local.hardware.altera = {
+ enable = mkEnableOption "Altera USB Blaster";
+ };
+
+ config = mkIf cfg.enable {
+ services.udev.extraRules = ''
+ # USB-Blaster
+ ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6001", MODE="660", GROUP="users", TAG+="uaccess"
+ ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6002", MODE="660", GROUP="users", TAG+="uaccess"
+ ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6003", MODE="660", GROUP="users", TAG+="uaccess"
+
+ # USB-Blaster II
+ ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6010", MODE="660", GROUP="users", TAG+="uaccess"
+ ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6810", MODE="660", GROUP="users", TAG+="uaccess"
+ '';
+ };
+}
diff --git a/sys/hardware/apc.nix b/sys/hardware/apc.nix
new file mode 100644
index 0000000..97a5bb0
--- /dev/null
+++ b/sys/hardware/apc.nix
@@ -0,0 +1,33 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.apc;
+in {
+ options.local.hardware.apc = {
+ enable = mkEnableOption "APC UPS support";
+ };
+
+ config = mkIf cfg.enable {
+ services.apcupsd = {
+ enable = true;
+
+ configText = concatStrings (mapAttrsToList (k: v: "${k} ${v}\n") {
+ UPSMODE = "disable";
+ UPSTYPE = "usb";
+ UPSCABLE = "usb";
+ UPSCLASS = "standalone";
+
+ NISIP = "127.0.0.1";
+ NETSERVER = "on";
+
+ MINUTES = "5";
+ BATTERYLEVEL = "10";
+
+ NOLOGON = "disable";
+ });
+ };
+ };
+}
diff --git a/sys/hardware/athena.nix b/sys/hardware/athena.nix
new file mode 100644
index 0000000..755c184
--- /dev/null
+++ b/sys/hardware/athena.nix
@@ -0,0 +1,48 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.athena;
+
+ athena = pkgs.local.athena-bccr.${cfg.release};
+in {
+ options.local.hardware.athena = {
+ enable = mkEnableOption "Athena ASEDrive III smartcard reader";
+
+ release = mkOption {
+ type = types.str;
+ default = "latest";
+ description = "athena-bccr release tag";
+ };
+ };
+
+ config = mkIf cfg.enable {
+ environment = {
+ etc = {
+ "Athena".source = "${athena.ase-pkcs11}/etc/Athena";
+
+ "pkcs11/modules/asep11".text = ''
+ module: ${athena.libasep11}
+ '';
+ };
+
+ systemPackages = [athena.ase-pkcs11];
+ };
+
+ #FIXME: Extremadamente peligroso si BCCR o MICITT caen, investigar política nacional de root CA
+ security.pki.certificateFiles = ["${athena.bccr-cacerts}/root-ca.pem"];
+
+ services = {
+ pcscd.enable = true;
+
+ #TODO: Sería mejor agregar un grupo separado
+ udev.extraRules = ''
+ # Athena Smartcard Solutions, Inc. ASEDrive V3CR
+ ATTRS{idVendor}=="0dc3", ATTRS{idProduct}=="1004", MODE="660", GROUP="users", TAG+="uaccess"
+ '';
+ };
+ };
+}
diff --git a/sys/hardware/bluetooth.nix b/sys/hardware/bluetooth.nix
new file mode 100644
index 0000000..63e3f0c
--- /dev/null
+++ b/sys/hardware/bluetooth.nix
@@ -0,0 +1,19 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.bluetooth;
+in {
+ options.local.hardware.bluetooth = {
+ enable = mkEnableOption "bluetooth services";
+ };
+
+ config = mkIf cfg.enable {
+ hardware.bluetooth = {
+ enable = true;
+ powerOnBoot = mkDefault false;
+ };
+ };
+}
diff --git a/sys/hardware/default.nix b/sys/hardware/default.nix
new file mode 100644
index 0000000..2ded912
--- /dev/null
+++ b/sys/hardware/default.nix
@@ -0,0 +1,13 @@
+{
+ imports = [
+ ./altera.nix
+ ./athena.nix
+ ./apc.nix
+ ./bluetooth.nix
+ ./epson.nix
+ ./laptop.nix
+ ./printing.nix
+ ./thinkpad.nix
+ ./yubico.nix
+ ];
+}
diff --git a/sys/hardware/epson.nix b/sys/hardware/epson.nix
new file mode 100644
index 0000000..30b1303
--- /dev/null
+++ b/sys/hardware/epson.nix
@@ -0,0 +1,38 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.epson;
+in {
+ options.local.hardware.epson = {
+ enable = mkEnableOption "Epson printers and scanners";
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = config.local.hardware.printing.enable;
+ message = "epson requires printing";
+ }
+ ];
+
+ hardware.sane = {
+ enable = true;
+
+ extraBackends = [
+ pkgs.epkowa
+ ];
+ };
+
+ services.printing = {
+ enable = true;
+
+ drivers = [
+ pkgs.epson_201207w
+ ];
+ };
+ };
+}
diff --git a/sys/hardware/laptop.nix b/sys/hardware/laptop.nix
new file mode 100644
index 0000000..3b5b772
--- /dev/null
+++ b/sys/hardware/laptop.nix
@@ -0,0 +1,19 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.laptop;
+in {
+ options.local.hardware.laptop = {
+ enable = mkEnableOption "laptop stuff";
+ };
+
+ config = mkIf cfg.enable {
+ services = {
+ tlp.enable = true;
+ upower.enable = true;
+ };
+ };
+}
diff --git a/sys/hardware/printing.nix b/sys/hardware/printing.nix
new file mode 100644
index 0000000..e11a016
--- /dev/null
+++ b/sys/hardware/printing.nix
@@ -0,0 +1,50 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.printing;
+ inherit (config.local.net) dhcpInterface;
+in {
+ options.local.hardware.printing = {
+ enable = mkEnableOption "print and scan services";
+
+ users = mkOption {
+ type = with types; listOf str;
+ default = [];
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = config.local.net.enable;
+ message = "Printing requires net";
+ }
+ ];
+
+ services.avahi = {
+ enable = true;
+ nssmdns4 = true;
+
+ # Abre 5353 en todas las interfaces (!!!)
+ openFirewall = false;
+ };
+
+ hardware.sane.enable = true;
+
+ networking.firewall.interfaces = mkIf (dhcpInterface != null) {
+ ${dhcpInterface}.allowedUDPPorts = [5353];
+ };
+
+ services.printing.enable = true;
+
+ users.users = listToAttrs (map
+ (user: {
+ name = user;
+ value.extraGroups = ["scanner" "lp"];
+ })
+ cfg.users);
+ };
+}
diff --git a/sys/hardware/thinkpad.nix b/sys/hardware/thinkpad.nix
new file mode 100644
index 0000000..ab18694
--- /dev/null
+++ b/sys/hardware/thinkpad.nix
@@ -0,0 +1,42 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.thinkpad;
+in {
+ options.local.hardware.thinkpad = {
+ enable = mkEnableOption "Thinkpad hardware support";
+ };
+
+ config = mkIf cfg.enable {
+ # For suspending to RAM to work, set Config -> Power -> Sleep State to "Linux" in EFI.
+ # See https://wiki.archlinux.org/index.php/Lenovo_ThinkPad_X1_Carbon_(Gen_6)#Suspend_issues
+ # Fingerprint sensor requires a firmware-update to work.
+
+ boot = {
+ extraModulePackages = with config.boot.kernelPackages; [acpi_call];
+ extraModprobeConfig = "options iwlwifi 11n_disable=1 wd_disable=1";
+
+ # acpi_call makes tlp work for newer thinkpads
+ kernelModules = ["acpi_call"];
+
+ # Force use of the thinkpad_acpi driver for backlight control.
+ # This allows the backlight save/load systemd service to work.
+ kernelParams = ["acpi_backlight=native"];
+ };
+
+ hardware.firmware = [pkgs.sof-firmware];
+
+ local.hardware.laptop.enable = true;
+
+ services = {
+ fprintd.enable = true;
+ thinkfan.enable = true;
+ tlp.enable = true;
+ tp-auto-kbbl.enable = true;
+ };
+ };
+}
diff --git a/sys/hardware/yubico.nix b/sys/hardware/yubico.nix
new file mode 100644
index 0000000..0c8478c
--- /dev/null
+++ b/sys/hardware/yubico.nix
@@ -0,0 +1,24 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.hardware.yubico;
+in {
+ options.local.hardware.yubico = {
+ enable = mkEnableOption "Yubico hardware support";
+ };
+
+ config = mkIf cfg.enable {
+ environment.etc."pkcs11/modules/ykcs11".text = ''
+ module: ${pkgs.yubico-piv-tool}/lib/libykcs11.so
+ '';
+
+ services = {
+ pcscd.enable = true;
+ udev.packages = [pkgs.yubikey-personalization];
+ };
+ };
+}
diff --git a/sys/home-assistant/default.nix b/sys/home-assistant/default.nix
new file mode 100644
index 0000000..e997c08
--- /dev/null
+++ b/sys/home-assistant/default.nix
@@ -0,0 +1,6 @@
+{
+ imports = [
+ ./hass.nix
+ ./yaml-extra.nix
+ ];
+}
diff --git a/sys/home-assistant/hass.nix b/sys/home-assistant/hass.nix
new file mode 100644
index 0000000..7fd3251
--- /dev/null
+++ b/sys/home-assistant/hass.nix
@@ -0,0 +1,79 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.home-assistant;
+in {
+ options.local.home-assistant = {
+ enable = mkEnableOption "home-assistant";
+ };
+
+ config = mkIf cfg.enable {
+ # https://nathan.gs/2024/06/22/fail2ban-to-secure-ha-on-nixos/
+ environment.etc."fail2ban/filter.d/home-assistant.local".text = ''
+ [Definition]
+ failregex = ^.* \[homeassistant\.components\.http\.ban\] Login attempt or request with invalid authentication from <HOST>.*$
+
+ ignoreregex =
+
+ journalmatch = _SYSTEMD_UNIT=home-assistant.service + _COMM=home-assistant
+
+ datepattern = {^LN-BEG}
+ '';
+
+ local.boot.impermanence.directories = [
+ {
+ directory = "/var/lib/hass";
+ user = "hass";
+ group = "hass";
+ mode = "u=rwx,g=,o=";
+ }
+ ];
+
+ services = {
+ fail2ban.jails.home-assistant = {};
+
+ home-assistant = {
+ enable = true;
+
+ extraComponents = [
+ "met"
+ "google_translate"
+ "radio_browser"
+ "tuya"
+ "wake_on_lan"
+ "webostv"
+ "xiaomi_miio"
+ ];
+
+ config = {
+ # Includes dependencies for a basic setup
+ # https://www.home-assistant.io/integrations/default_config/
+ default_config = {};
+
+ switch = [
+ # Televisor 192.168.42.205
+ # TODO: No sirve por 192.168.34 vs 192.168.42
+ {
+ platform = "wake_on_lan";
+ mac = "74:40:be:58:5f:da";
+ }
+ ];
+ };
+
+ customComponents = with pkgs.home-assistant-custom-components; [
+ dreame_vacuum
+ smartthinq_sensors
+ xiaomi_miot
+ ];
+
+ customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [
+ xiaomi-vacuum-map-card
+ ];
+ };
+ };
+ };
+}
diff --git a/sys/home-assistant/yaml-extra.nix b/sys/home-assistant/yaml-extra.nix
new file mode 100644
index 0000000..77d1ed2
--- /dev/null
+++ b/sys/home-assistant/yaml-extra.nix
@@ -0,0 +1,23 @@
+{lib, ...}:
+with lib; {
+ options.services.home-assistant = {
+ config = mkOption {
+ type = with lib.types;
+ nullOr (submodule {
+ options = {
+ http = {
+ use_x_forwarded_for = mkOption {
+ type = nullOr bool;
+ default = null;
+ };
+
+ trusted_proxies = mkOption {
+ type = nullOr (either str (listOf str));
+ default = null;
+ };
+ };
+ };
+ });
+ };
+ };
+}
diff --git a/sys/jobs/default.nix b/sys/jobs/default.nix
new file mode 100644
index 0000000..24d6c73
--- /dev/null
+++ b/sys/jobs/default.nix
@@ -0,0 +1,5 @@
+{
+ imports = [
+ ./pki-expiry
+ ];
+}
diff --git a/sys/jobs/pki-expiry/default.nix b/sys/jobs/pki-expiry/default.nix
new file mode 100644
index 0000000..553cdc8
--- /dev/null
+++ b/sys/jobs/pki-expiry/default.nix
@@ -0,0 +1,60 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.jobs.pkiExpiry;
+ inherit (config.local) pki;
+in {
+ options.local.jobs.pkiExpiry = {
+ enable = mkEnableOption "PKI expiration reminder";
+ };
+
+ config = mkIf cfg.enable {
+ systemd = {
+ services.pki-expiry = {
+ after = ["postfix.service"];
+ path = ["/run/wrappers"];
+
+ environment.PKI_PUBLIC = let
+ mkdir = "mkdir -p $out/{ca,cert,crl}";
+
+ cas = mapAttrsToList (_: ca: "ln -s ${ca.cert} $out/ca/${ca.path}") pki.ca;
+ crls = mapAttrsToList (_: ca: "ln -s ${ca.crl} $out/crl/${ca.path}") pki.ca;
+
+ certs =
+ mapAttrsToList
+ (path: leaf: "ln -s ${leaf.cert} $out/cert/${path}")
+ (filterAttrs (_: object: ! object ? leaves) pki.byPath);
+
+ pkiPublic = pkgs.runCommandNoCCLocal "pki-public" {} (concatLines ([mkdir] ++ cas ++ crls ++ certs));
+ in "${pkiPublic}";
+
+ serviceConfig = {
+ Type = "oneshot";
+ StateDirectory = "pki-expiry";
+ WorkingDirectory = "/var/lib/pki-expiry";
+
+ ExecStart = let
+ script = pkgs.writeShellApplication {
+ name = "pki-expiry";
+ text = readFile ./pki-expiry.sh;
+ runtimeInputs = with pkgs; [diffutils openssl];
+ };
+ in "${getExe script}";
+ };
+ };
+
+ timers.pki-expiry = {
+ wantedBy = ["timers.target"];
+
+ timerConfig = {
+ OnStartupSec = "10m";
+ OnUnitInactiveSec = "3d";
+ };
+ };
+ };
+ };
+}
diff --git a/sys/jobs/pki-expiry/pki-expiry.sh b/sys/jobs/pki-expiry/pki-expiry.sh
new file mode 100644
index 0000000..0e95a26
--- /dev/null
+++ b/sys/jobs/pki-expiry/pki-expiry.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+#
+function will_expire() {
+ expiry_status=""
+ expiry_vars="$(openssl "$openssl_cmd" -in "$object_path" -noout "${openssl_var_opts[@]}")"
+
+ expiry_date="$(echo "$expiry_vars" | grep "^$openssl_expiry_var=" | sed 's/^.\+=//g')"
+ if [ -z "$expiry_date" ]; then
+ return 1
+ fi
+
+ expiry_secs="$(date +%s -d "$expiry_date")"
+ diff="$((expiry_secs - now))"
+
+ if [ "$diff" -gt "$1" ]; then
+ return 1
+ elif [ "$diff" -lt 0 ]; then
+ remaining=0
+ else
+ remaining="$((diff / 86400))"
+ fi
+
+ total_matches="$((total_matches + 1))"
+
+ if [ -z "$min_expiry" ]; then
+ min_expiry="$remaining"
+ elif [ "$remaining" -lt "$min_expiry" ]; then
+ min_expiry="$remaining"
+ fi
+}
+
+function has_expired() {
+ if ! will_expire 0; then
+ return 1
+ fi
+
+ expiry_status="has expired"
+}
+
+function will_expire_days() {
+ if ! will_expire "$(($1 * 86400))"; then
+ return 1
+ fi
+
+ expiry_status="will expire in $remaining days"
+}
+
+function check_object() {
+ object_id="$(basename "$1")"
+ object_path="$1"
+
+ if has_expired || will_expire_days 3 || will_expire_days 7 || will_expire_days 15 || will_expire_days 30; then
+ {
+ echo
+ echo "$object_repr '$object_id' $expiry_status"
+ echo "$expiry_vars"
+ } >>"$mail_out"
+ fi
+}
+
+function check_dir() {
+ object_repr="$2"
+
+ for path in "$PKI_PUBLIC/$1"/*; do
+ check_object "$path"
+ done
+}
+
+if [ -z "$PKI_PUBLIC" ]; then
+ echo "$0: \$PKI_PUBLIC not set"
+ exit 1
+elif [ ! -d "$PKI_PUBLIC" ]; then
+ echo "$0: invalid \$PKI_PUBLIC: $PKI_PUBLIC"
+ exit 1
+fi
+
+mail_out="$(mktemp)"
+trap 'rm -f -- "$mail_out"' EXIT
+
+now="$(date +%s)"
+min_expiry=""
+total_matches=0
+
+openssl_cmd=x509
+openssl_var_opts=(-startdate -enddate)
+openssl_expiry_var="notAfter"
+
+check_dir ca "CA"
+check_dir cert "Certificate"
+
+openssl_cmd=crl
+openssl_var_opts=(-lastupdate -nextupdate)
+openssl_expiry_var="nextUpdate"
+
+check_dir crl "CRL for CA"
+
+if [ -s "$mail_out" ] && ! cmp -s last-mail "$mail_out"; then
+ sendmail -t <<- EOF
+ From: PKI expiration reminder <pki-expiry>
+ To: sysadmin
+ Subject: $total_matches PKI objects will expire in $min_expiry days
+
+ The following PKI objects are due for renewal:
+ $(<"$mail_out")
+ EOF
+
+ mv -- "$mail_out" last-mail
+fi
diff --git a/sys/kiosk/default.nix b/sys/kiosk/default.nix
new file mode 100644
index 0000000..be20829
--- /dev/null
+++ b/sys/kiosk/default.nix
@@ -0,0 +1,48 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.kiosk;
+in {
+ options.local.kiosk = {
+ enable = mkEnableOption "kiosk mode";
+
+ program = mkOption {
+ type = types.str;
+ };
+
+ user = mkOption {
+ type = types.str;
+ };
+
+ allowVTSwitch = mkOption {
+ type = types.bool;
+ default = false;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ services = {
+ cage = {
+ enable = true;
+ inherit (cfg) program user;
+
+ extraArguments = [
+ (
+ if cfg.allowVTSwitch
+ then "-sd"
+ else "-d"
+ )
+ ];
+ };
+
+ physlock = {
+ enable = true;
+ disableSysRq = true;
+ muteKernelMessages = true;
+ };
+ };
+ };
+}
diff --git a/sys/mail/default.nix b/sys/mail/default.nix
new file mode 100644
index 0000000..f87b6fe
--- /dev/null
+++ b/sys/mail/default.nix
@@ -0,0 +1,246 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.mailHost;
+ imapHostname = config.local.domains.imap.main;
+
+ cert = config.security.acme.certs.${imapHostname}.directory;
+
+ inherit (config.local) users virtual;
+in {
+ options.local.mailHost = {
+ enable = mkEnableOption "mailbox host service";
+
+ mdaListen = mkOption {
+ type = types.str;
+ };
+
+ saslPort = mkOption {
+ type = types.port;
+ };
+
+ lmtpPort = mkOption {
+ type = types.port;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ # 25.05: The option definition `services.dovecot2.modules' in
+ # `/nix/store/d3mfmsa6klf9dizidvs9qgfv4bgxqgvz-source/sys/mail' no longer
+ # has any effect; please remove it. Now need to use
+ # `environment.systemPackages` to load additional Dovecot modules
+ environment.systemPackages = [
+ pkgs.dovecot_pigeonhole
+ ];
+
+ services = {
+ dovecot2 = {
+ enable = true;
+ enablePAM = false;
+ enableLmtp = true;
+
+ sslServerKey = "${cert}/key.pem";
+ sslServerCert = "${cert}/fullchain.pem";
+
+ mailUser = "vmail";
+ mailGroup = "vmail";
+ mailLocation = "maildir:~/mail";
+ mailPlugins.perProtocol.lmtp.enable = ["sieve"];
+
+ # https://github.com/NixOS/nixpkgs/issues/286859
+ sieve.extensions = [
+ "fileinto"
+ "mailbox"
+ ];
+
+ extraConfig = let
+ inherit (config.networking) domain;
+
+ # https://dovecot.org/list/dovecot/2019-March/115250.html
+ # Otra solución posible (https://serverfault.com/a/1062274/980378):
+ # auth_username_format = %{if;%d;eq;${domain};%Ln;%Lu}
+ localEntry = canonical: username: ''
+ ${username}:::::::user=${canonical} nopassword userdb_user=${canonical}
+ '';
+
+ localMailboxes =
+ pkgs.writeText "local-mailboxes"
+ (concatStrings
+ (flatten (mapAttrsToList
+ (canonical: user:
+ map (localEntry canonical) ([canonical] ++ user.hardAliases))
+ users)));
+
+ localCerts = flatten (mapAttrsToList
+ (canonical: user: let
+ certNames = {
+ inherit canonical;
+ logins = [canonical] ++ user.hardAliases;
+ };
+ in
+ map (flip nameValuePair certNames) user.mail.certs)
+ users);
+
+ vmailCerts = flatten (flatten (mapAttrsToList
+ (domain: virtual:
+ mapAttrsToList
+ (username: user: let
+ address = "${username}@${domain}";
+
+ certNames = {
+ canonical = address;
+ logins = [address];
+ };
+ in
+ map (flip nameValuePair certNames) user.mail.certs)
+ virtual.users)
+ virtual));
+
+ certLogins =
+ pkgs.writeText "cert-logins"
+ (concatLines (flatten (mapAttrsToList
+ (certPath: names:
+ map
+ (addr: "${config.local.pki.byPath.${certPath}.commonName}@nodomain,${addr}:::::::user=${names.canonical}")
+ names.logins)
+ (listToAttrs (localCerts ++ vmailCerts)))));
+
+ vmailPath = "/var/lib/vmail/%{if;%d;ne;;%Ld;${domain}}";
+ in ''
+ auth_mechanisms = plain login external
+
+ ssl_ca = <${config.local.pki.ca.mail.fullchain}
+ ssl_require_crl = yes
+ ssl_verify_client_cert = yes
+
+ # Esto descarta @domain.tld de locales explícitos, pero lo exige para los demás.
+ # Implicación: locales implícitos sin dominio fallan en autenticar
+ auth_username_format = %{if;%Ld;eq;${domain};%Ln;%{if;%d;ne;;%Lu;%Ln@nodomain}}
+ auth_ssl_username_from_cert = yes
+
+ # TODO: los defaults de nixpkgs dejan los sockets bajo
+ # /run/dovecot2 con demasiados permisos rwx, arreglar
+
+ service auth {
+ inet_listener mta-sasl {
+ port = ${toString cfg.saslPort}
+ address = ${cfg.mdaListen}
+ }
+ }
+
+ service lmtp {
+ inet_listener mta-lmtp {
+ port = ${toString cfg.lmtpPort}
+ address = ${cfg.mdaListen}
+ }
+ }
+
+ # FIXME: Esta cadena de passdbs hace que 'doveadm user lookup'
+ # falle para usuarios locales, pero todo lo demás sirve. Parece
+ # ser debido a que pam no puede enumerar.
+
+ passdb {
+ driver = static
+ args = nopassword
+
+ master = yes
+ mechanisms = external
+
+ result_success = continue-fail
+ result_failure = return-fail
+ result_internalfail = return-fail
+ }
+
+ passdb {
+ driver = passwd-file
+ args = scheme=PLAIN username_format=%{master_user},%Lu ${certLogins}
+
+ mechanisms = external
+ override_fields = nopassword
+
+ result_failure = return-fail
+ result_internalfail = return-fail
+ }
+
+ passdb {
+ driver = passwd-file
+ args = username_format=%Ln ${vmailPath}/passwd
+ }
+
+ passdb {
+ driver = passwd-file
+ args = scheme=PLAIN ${localMailboxes}
+
+ # Esta es una forma de determinar si se encontró el usuario en
+ # el passwd-file por medio de nopassword sin realmente
+ # autenticarlo. Cuidado con result_success, porque si eso se
+ # configura mal se permite inicio de sesión con cualquier
+ # contraseña (!!!).
+ result_success = continue
+ result_failure = return-fail
+ result_internalfail = return-fail
+
+ username_filter = !*@*
+ }
+
+ passdb {
+ driver = pam
+ args = dovecot2
+ username_filter = !*@*
+ #TODO: algo como 'override_fields = allow_nets=...'
+ }
+
+ userdb {
+ driver = passwd-file
+ args = username_format=%Ln ${vmailPath}/passwd
+ override_fields = uid=vmail gid=vmail home=${vmailPath}/home/%Ln
+ }
+
+ userdb {
+ driver = passwd-file
+ args = ${localMailboxes}
+
+ result_success = continue-ok
+ result_internalfail = return-fail
+ skip = found
+ }
+
+ userdb {
+ driver = passwd
+ args = blocking=no
+ skip = notfound
+ }
+ '';
+ };
+
+ fail2ban.jails.dovecot.settings = {
+ filter = "dovecot[mode=aggressive]";
+ maxretry = 3;
+ };
+ };
+
+ security = {
+ # Necesario debido a 'enablePAM = false'
+ pam.services.dovecot2 = {};
+
+ acme.certs.${imapHostname} = {
+ inherit (config.services.dovecot2) group;
+
+ reloadServices = ["dovecot2.service"];
+ };
+ };
+
+ users = {
+ users.${config.services.dovecot2.mailUser}.uid = 995;
+ groups.${config.services.dovecot2.mailGroup}.gid = 993;
+ };
+
+ networking.firewall.allowedTCPPorts = [143 993];
+
+ local.certs.imap.enable = true;
+ };
+}
diff --git a/sys/mta/default.nix b/sys/mta/default.nix
new file mode 100644
index 0000000..57c1c27
--- /dev/null
+++ b/sys/mta/default.nix
@@ -0,0 +1,269 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.mta;
+
+ inherit (config.local) domains virtual users;
+ inherit (config.networking) domain;
+
+ isBackup = cfg.mode == "backup";
+ isPrimary = cfg.mode == "primary";
+
+ allDomains = optional (! virtualDomains ? ${domain}) domain ++ attrNames virtualDomains;
+ virtualDomains = filterAttrs (name: _: name != domain) virtual;
+
+ cert = config.security.acme.certs.${mtaDomain.main}.directory;
+
+ mtaDomain =
+ if isPrimary
+ then domains.smtp
+ else domains.smtp-backup;
+
+ mdaTransport =
+ if isPrimary
+ then "lmtp:inet:${cfg.mdaAddr}:${toString cfg.lmtpPort}"
+ else "error:bad transport";
+in {
+ options.local.mta = {
+ enable = mkEnableOption "mail transfer agent";
+
+ mode = mkOption {
+ type = types.enum ["primary" "backup"];
+ };
+
+ mdaAddr = mkOption {
+ type = types.str;
+ };
+
+ saslPort = mkOption {
+ type = types.port;
+ };
+
+ lmtpPort = mkOption {
+ type = types.port;
+ };
+
+ relayListen = mkOption {
+ type = types.str;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ services = {
+ fail2ban.jails.postfix.settings = {
+ filter = "postfix[mode=aggressive]";
+ };
+
+ opendkim = mkIf isPrimary {
+ enable = true;
+
+ group = "postfix";
+ domains = "csl:" + concatStringsSep "," ([domain] ++ attrNames virtualDomains);
+ selector = "202408";
+
+ configFile = pkgs.writeText "opendkim.conf" ''
+ UMask 007
+ InternalHosts 0.0.0.0/0,::/0
+ '';
+ };
+
+ postfix = {
+ enable = true;
+ enableSmtp = true;
+ enableSubmissions = isPrimary;
+
+ inherit domain;
+ hostname = mtaDomain.main;
+
+ #TODO: check_recipient_access para rechazar localhost desde afuera
+ destination = optionals isPrimary ["localhost" "$mydomain"];
+ origin = "$mydomain";
+
+ networksStyle = "host";
+
+ relayHost = optionalString isBackup domains.smtp.main;
+ lookupMX = false;
+
+ relayDomains =
+ if isBackup
+ then allDomains
+ else null;
+
+ sslKey = "${cert}/key.pem";
+ sslCert = "${cert}/fullchain.pem";
+
+ # También es postmaster
+ rootAlias = config.local.sysadmin;
+
+ extraAliases =
+ optionalString isPrimary
+ (concatLines (flatten (mapAttrsToList
+ (name: user:
+ map
+ (alias: "${alias}: ${name}")
+ user.hardAliases)
+ users)));
+
+ localRecipients =
+ optionals isPrimary
+ (map (user: "${user}@${domain}")
+ (attrNames (users // virtual.${domain}.users)));
+
+ virtual =
+ optionalString isPrimary
+ (concatLines (flatten (mapAttrsToList
+ (name: virtual:
+ mapAttrsToList
+ (alias: targets: "${alias}@${name} ${concatStringsSep ", " targets}")
+ virtual.aliases)
+ virtual)));
+
+ mapFiles = optionalAttrs isPrimary {
+ sender_ccerts =
+ pkgs.writeText "postfix-sender_ccerts"
+ (concatLines (flatten (mapAttrsToList
+ (username: user:
+ map
+ (alias: "${alias}@${domain} CCERTS ${concatStringsSep ","
+ (map (certPath: config.local.pki.byPath.${certPath}.fingerprint.sha256-bytes-upper)
+ user.mail.certs)}")
+ ([username] ++ user.hardAliases))
+ (filterAttrs (_: user: user.mail.certs != []) users))));
+
+ sender_login =
+ pkgs.writeText "postfix-sender_login"
+ (concatLines (flatten (mapAttrsToList
+ (username: user:
+ map
+ (alias: "${alias}@${domain} ${username}")
+ ([username] ++ user.hardAliases))
+ users)));
+
+ virtual_recipients =
+ pkgs.writeText "postfix-virtual_recipients"
+ (concatLines (flatten (mapAttrsToList
+ (virtualDomain: virtual:
+ mapAttrsToList
+ # El lado derecho de esta tabla debe existir pero nunca se usa
+ (username: _: "${username}@${virtualDomain} foo")
+ virtual.users)
+ virtualDomains)));
+
+ virtual_rules =
+ pkgs.writeText "postfix-virtual_rules"
+ (concatLines (flatten (mapAttrsToList
+ (name: virtual:
+ map
+ (rule: "/^${rule.pattern}@${name}$/ ${concatStringsSep ", " rule.targets}")
+ virtual.rules)
+ virtual)));
+ };
+
+ config =
+ {
+ # user+extension@domain.tld
+ recipient_delimiter = optionalString isPrimary "+";
+
+ message_size_limit = toString (50 * 1048576);
+
+ local_transport = mdaTransport;
+ virtual_transport = mdaTransport;
+
+ smtpd_tls_auth_only = true;
+ # Nota: smtpd_tls_dh1024_param_file fue deprecado en 3.9
+
+ tls_append_default_CA = false; # Crítico
+
+ # https://linux-audit.com/postfix-hardening-guide-for-security-and-privacy/
+ smtpd_helo_required = true;
+ disable_vrfy_command = true;
+ }
+ // optionalAttrs isPrimary {
+ virtual_alias_maps = mkAfter ["pcre:/etc/postfix/virtual_rules"];
+ virtual_mailbox_domains = attrNames virtualDomains;
+ virtual_mailbox_maps = ["hash:/etc/postfix/virtual_recipients"];
+
+ smtpd_sasl_type = "dovecot";
+ smtpd_sasl_path = "inet:${cfg.mdaAddr}:${toString cfg.saslPort}";
+ smtpd_sasl_local_domain = "$mydomain";
+ smtpd_sasl_security_options = ["noanonymous"];
+
+ smtpd_tls_CAfile = "${config.local.pki.ca.mail.fullchain}";
+ smtpd_tls_ccert_verifydepth = "1";
+
+ # Inventado, no es parámetro de postfix
+ local_submission_client_restrictions = [
+ "permit_tls_all_clientcerts"
+ "permit_sasl_authenticated"
+ "reject"
+ ];
+
+ smtpd_sender_login_maps = ["hash:/etc/postfix/sender_login"];
+
+ smtpd_relay_restrictions = [
+ "permit_mynetworks"
+ "permit_tls_all_clientcerts"
+ "permit_sasl_authenticated"
+ "reject_unauth_destination"
+ ];
+
+ smtpd_sender_restrictions = [
+ "check_sender_access hash:/etc/postfix/sender_ccerts"
+ "reject_sender_login_mismatch"
+ ];
+
+ smtpd_milters = "unix:/run/opendkim/opendkim.sock";
+ non_smtpd_milters = "$smtpd_milters";
+ milter_default_action = "accept";
+ }
+ // optionalAttrs isBackup {
+ inet_interfaces = [cfg.relayListen];
+
+ smtpd_relay_restrictions = [
+ "reject_unauth_destination"
+ ];
+ };
+
+ # Importante: existe submissionOptions por aparte, no son iguales
+ submissionsOptions = optionalAttrs isPrimary {
+ smtpd_client_restrictions = "$local_submission_client_restrictions";
+ smtpd_sasl_auth_enable = "yes";
+ smtpd_tls_ask_ccert = "yes";
+ smtpd_tls_security_level = "encrypt";
+ };
+ };
+ };
+
+ #TODO: solo para las destination addresses necesarias
+ networking.firewall.allowedTCPPorts = optionals isPrimary [25 465];
+
+ local = {
+ boot.impermanence.directories =
+ [
+ {
+ directory = "/var/lib/postfix";
+ user = "root";
+ group = "root";
+ mode = "u=rwx,g=rx,o=rx";
+ }
+ ]
+ ++ optionals isPrimary [
+ {
+ directory = "/var/lib/opendkim";
+ user = "opendkim";
+ group = "postfix";
+ mode = "u=rwx,g=,o=";
+ }
+ ];
+
+ certs.smtp.enable = isPrimary;
+ certs.smtp-backup.enable = isBackup;
+ };
+
+ security.acme.certs.${mtaDomain.main}.reloadServices = ["postfix.service"];
+ };
+}
diff --git a/sys/net/default.nix b/sys/net/default.nix
new file mode 100644
index 0000000..c3c5740
--- /dev/null
+++ b/sys/net/default.nix
@@ -0,0 +1,9 @@
+{
+ imports = [
+ ./fail2ban.nix
+ ./interfaces.nix
+ ./nets.nix
+ ./options.nix
+ ./vsock.nix
+ ];
+}
diff --git a/sys/net/fail2ban.nix b/sys/net/fail2ban.nix
new file mode 100644
index 0000000..32197b6
--- /dev/null
+++ b/sys/net/fail2ban.nix
@@ -0,0 +1,37 @@
+{
+ lib,
+ config,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.net.fail2ban;
+ inherit (config.local) nets;
+in {
+ options.local.net.fail2ban = {
+ enable = mkEnableOption "fail2ban";
+ };
+
+ config = mkIf cfg.enable {
+ services.fail2ban = {
+ enable = true;
+
+ bantime = "10m";
+
+ bantime-increment = {
+ enable = true;
+
+ maxtime = "48h";
+ rndtime = "10m";
+ overalljails = true;
+ };
+
+ ignoreIP = [
+ nets.static-vpn.v6.cidr
+ nets.gate-srv.v6.cidr
+ nets.gate-public.hosts.gate.v4.address
+ nets.gate-public.hosts.gate.v6.address
+ ];
+ };
+ };
+}
diff --git a/sys/net/interfaces.nix b/sys/net/interfaces.nix
new file mode 100644
index 0000000..3b0abcd
--- /dev/null
+++ b/sys/net/interfaces.nix
@@ -0,0 +1,119 @@
+{
+ lib,
+ config,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.net;
+in {
+ options.local.net = with lib.types; {
+ enable = mkEnableOption "networking stack";
+
+ hostname = mkOption {
+ type = str;
+ };
+
+ dhcpInterface = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ boot.kernel.sysctl = {
+ # rp_filter=1 reemplazado por nixos-fw-rpfilter
+ "net.ipv4.conf.all.rp_filter" = mkForce 2;
+ "net.ipv4.conf.default.rp_filter" = mkForce 2;
+
+ "net.ipv4.conf.all.forwarding" = mkForce true;
+ "net.ipv6.conf.all.forwarding" = mkForce true;
+ "net.ipv4.conf.default.forwarding" = mkForce true;
+ "net.ipv6.conf.default.forwarding" = mkForce true;
+
+ "net.ipv4.conf.all.accept_redirects" = mkForce false;
+ "net.ipv6.conf.all.accept_redirects" = mkForce false;
+ "net.ipv4.conf.default.accept_redirects" = mkForce false;
+ "net.ipv6.conf.default.accept_redirects" = mkForce false;
+ };
+
+ environment.systemPackages = with pkgs; [
+ conntrack-tools
+ dhcpcd
+ dnsutils
+ nmap
+ socat
+ tcpdump
+ ];
+
+ networking = {
+ domain = mkDefault config.local.domains.host.main;
+ hostName = cfg.hostname;
+
+ firewall = {
+ logReversePathDrops = true;
+ checkReversePath = "loose";
+
+ extraCommands = mkBefore ''
+ ip46tables -t filter -P INPUT DROP
+ ip46tables -t filter -P FORWARD ACCEPT #TODO: DROP
+
+ ip46tables -t filter -N local-input
+ ip46tables -t filter -N local-forward
+ ip46tables -t nat -N local-prerouting
+ ip46tables -t nat -N local-postrouting
+
+ ip46tables -t filter -I INPUT -j local-input
+ ip46tables -t filter -I FORWARD -j local-forward
+ ip46tables -t nat -I PREROUTING -j local-prerouting
+ ip46tables -t nat -I POSTROUTING -j local-postrouting
+
+ ip46tables -t filter -A local-forward -m conntrack --ctstate RELATED,ESTABLISHED,SNAT,DNAT -j ACCEPT
+ '';
+
+ extraStopCommands = mkAfter ''
+ ip46tables -t filter -D INPUT -j local-input || true
+ ip46tables -t filter -D FORWARD -j local-forward || true
+ ip46tables -t nat -D PREROUTING -j local-prerouting || true
+ ip46tables -t nat -D POSTROUTING -j local-postrouting || true
+
+ ip46tables -t filter -F local-input || true
+ ip46tables -t filter -X local-input || true
+ ip46tables -t filter -F local-forward || true
+ ip46tables -t filter -X local-forward || true
+ ip46tables -t nat -F local-prerouting || true
+ ip46tables -t nat -X local-prerouting || true
+ ip46tables -t nat -F local-postrouting || true
+ ip46tables -t nat -X local-postrouting || true
+
+ ip46tables -t filter -P INPUT ACCEPT
+ ip46tables -t filter -P FORWARD ACCEPT
+ '';
+
+ logRefusedConnections = false;
+ };
+
+ useDHCP = false;
+ enableIPv6 = mkDefault true;
+ useNetworkd = mkDefault true;
+ useHostResolvConf = false;
+
+ wireguard.enable = true;
+ };
+
+ systemd.network.networks = mkIf (cfg.dhcpInterface != null) {
+ "40-${cfg.dhcpInterface}" = {
+ matchConfig.Name = cfg.dhcpInterface;
+
+ networkConfig = {
+ DHCP = "ipv4";
+ IPv6AcceptRA = true;
+ IPv6PrivacyExtensions = "kernel";
+ };
+
+ # make routing on this interface a dependency for network-online.target
+ linkConfig.RequiredForOnline = "routable";
+ };
+ };
+ };
+}
diff --git a/sys/net/nets.nix b/sys/net/nets.nix
new file mode 100644
index 0000000..1bb3788
--- /dev/null
+++ b/sys/net/nets.nix
@@ -0,0 +1 @@
+# This file has been lustrated.
diff --git a/sys/net/options.nix b/sys/net/options.nix
new file mode 100644
index 0000000..0608fb9
--- /dev/null
+++ b/sys/net/options.nix
@@ -0,0 +1,278 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ v4PtrHierarchy = address: bits: reverseList (sublist 0 (bits / 8) (splitString "." address));
+
+ v6PtrHierarchy = address: bits: let
+ separator = lists.findFirstIndex (hextet: hextet == "") null colonSplit;
+ colonSplit = splitString ":" address;
+
+ zeroFill = replicate (8 - length colonSplit + 1) "0000";
+ leftSplit = sublist 0 separator colonSplit;
+ rightSplit = sublist (separator + 1) (length colonSplit - separator - 1) colonSplit;
+
+ fullSplit =
+ if separator != null
+ then leftSplit ++ zeroFill ++ rightSplit
+ else colonSplit;
+
+ padded = map (hextet: strings.replicate (4 - stringLength hextet) "0" + hextet) fullSplit;
+ in
+ reverseList (sublist 0 (bits / 4) (flatten (map stringToCharacters padded)));
+
+ matchPtrRecordName = {
+ splitter,
+ netAddress,
+ netBits,
+ targetAddress,
+ targetBits,
+ }: let
+ netSplit = splitter netAddress netBits;
+ targetSplit = splitter targetAddress targetBits;
+
+ netLength = length netSplit;
+ lengthDelta = length targetSplit - netLength;
+
+ withinNet = lengthDelta >= 0 && sublist lengthDelta netLength targetSplit == netSplit;
+ throwMessage = "${targetAddress}/${toString targetBits} is not a subset of ${netAddress}/${toString netBits}";
+
+ recordHierarchy = sublist 0 lengthDelta targetSplit;
+
+ recordName =
+ if recordHierarchy != []
+ then concatStringsSep "." recordHierarchy
+ else "@";
+ in
+ throwIfNot withinNet throwMessage recordName;
+in {
+ options.local.nets = with lib.types;
+ mkOption {
+ readOnly = true;
+
+ type = attrsOf (submodule ({config, ...}: {
+ options = let
+ v4config = config.v4;
+ v6config = config.v6;
+ in {
+ hosts = mkOption {
+ default = {};
+
+ type = attrsOf (submodule {
+ options = {
+ v4 = mkOption {
+ default = null;
+
+ type = nullOr (submodule ({config, ...}: {
+ options = {
+ suffix = mkOption {
+ type = str;
+ };
+
+ address = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ cidr = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ single = mkOption {
+ type = str;
+ readOnly = true;
+ };
+ };
+
+ config = {
+ address =
+ if v4config.bits == 0
+ then config.suffix
+ else if v4config.bits == 32
+ then v4config.subnet
+ else "${v4config.prefix}.${config.suffix}";
+
+ cidr = "${config.address}/${toString v4config.bits}";
+ single = "${config.address}/32";
+ };
+ }));
+ };
+
+ v6 = mkOption {
+ default = null;
+
+ type = nullOr (submodule ({config, ...}: {
+ options = {
+ suffix = mkOption {
+ type = str;
+ };
+
+ address = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ cidr = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ single = mkOption {
+ type = str;
+ readOnly = true;
+ };
+ };
+
+ config = {
+ address = let
+ hextets = fragment: length (splitString ":" fragment);
+ separator =
+ if doubleColon
+ then "::"
+ else ":";
+ doubleColon = hextets v6config.prefix + hextets config.suffix < 8;
+
+ joined =
+ if v6config.bits == 128
+ then v6config.prefix
+ else if v6config.bits == 0
+ then config.suffix
+ else "${v6config.prefix}${separator}${config.suffix}";
+ in
+ joined;
+
+ cidr = "${config.address}/${toString v6config.bits}";
+ single = "${config.address}/128";
+ };
+ }));
+ };
+ };
+ });
+ };
+
+ v4 = mkOption {
+ default = null;
+
+ type = nullOr (submodule ({config, ...}: {
+ options = {
+ bits = mkOption {
+ type = enum [0 8 16 24 32];
+ };
+
+ prefix = mkOption {
+ type = str;
+ };
+
+ subnet = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ cidr = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ ptrDomain = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ ptrRecordName = mkOption {
+ type = functionTo (functionTo str);
+ readOnly = true;
+ };
+ };
+
+ config = {
+ cidr = "${config.subnet}/${toString config.bits}";
+
+ subnet =
+ if config.bits != 0
+ then config.prefix + strings.replicate (4 - config.bits / 8) ".0"
+ else "0.0.0.0";
+
+ ptrDomain = concatStrings (map (x: x + ".") (v4PtrHierarchy config.subnet config.bits)) + "in-addr.arpa";
+
+ ptrRecordName = address: bits:
+ matchPtrRecordName {
+ splitter = v4PtrHierarchy;
+
+ netBits = config.bits;
+ netAddress = config.subnet;
+
+ targetBits = bits;
+ targetAddress = address;
+ };
+ };
+ }));
+ };
+
+ v6 = mkOption {
+ default = null;
+
+ type = nullOr (submodule ({config, ...}: {
+ options = {
+ bits = mkOption {
+ type =
+ addCheck (ints.between 0 128) (b: mod b 4 == 0)
+ // {
+ description = "IPv6 subnet bits at nibble boundary";
+ };
+ };
+
+ prefix = mkOption {
+ type = str;
+ };
+
+ subnet = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ cidr = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ ptrDomain = mkOption {
+ type = str;
+ readOnly = true;
+ };
+
+ ptrRecordName = mkOption {
+ type = functionTo (functionTo str);
+ readOnly = true;
+ };
+ };
+
+ config = {
+ cidr = "${config.subnet}/${toString config.bits}";
+
+ subnet =
+ if config.bits == 128 || length (splitString "::" config.prefix) > 1
+ then config.prefix
+ else "${config.prefix}::";
+
+ ptrDomain = concatStrings (map (x: x + ".") (v6PtrHierarchy config.subnet config.bits)) + "ip6.arpa";
+
+ ptrRecordName = address: bits:
+ matchPtrRecordName {
+ splitter = v6PtrHierarchy;
+
+ netBits = config.bits;
+ netAddress = config.subnet;
+
+ targetBits = bits;
+ targetAddress = address;
+ };
+ };
+ }));
+ };
+ };
+ }));
+ };
+}
diff --git a/sys/net/vsock.nix b/sys/net/vsock.nix
new file mode 100644
index 0000000..c6b0ad6
--- /dev/null
+++ b/sys/net/vsock.nix
@@ -0,0 +1,63 @@
+{
+ lib,
+ config,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.net.vsock;
+in {
+ options.local.net.vsock = {
+ connect = mkOption {
+ default = {};
+ type = with lib.types;
+ attrsOf (submodule ({name, ...}: {
+ options = {
+ enable = mkEnableOption "vsock connect '${name}'";
+
+ cid = mkOption {
+ type = ints.u32;
+ default = 2;
+ };
+
+ localPort = mkOption {
+ type = port;
+ };
+
+ vsockPort = mkOption {
+ type = port;
+ };
+ };
+ }));
+ };
+ };
+
+ config = {
+ systemd = let
+ connects =
+ mapAttrs
+ (_: connect: {
+ service.serviceConfig = {
+ Type = "simple";
+ ExecStart = "${getExe pkgs.socat} - VSOCK:${toString connect.cid}:${toString connect.vsockPort}";
+ StandardInput = "socket";
+ };
+
+ socket = {
+ wantedBy = ["sockets.target"];
+
+ socketConfig = {
+ Accept = true;
+ ListenStream = "[::1]:${toString connect.localPort}";
+ };
+
+ unitConfig.ConditionVirtualization = "kvm";
+ };
+ })
+ cfg.connect;
+ in {
+ sockets = mapAttrs' (name: connect: nameValuePair "vsock-${name}" connect.socket) connects;
+ services = mapAttrs' (name: connect: nameValuePair "vsock-${name}@" connect.service) connects;
+ };
+ };
+}
diff --git a/sys/ns/default.nix b/sys/ns/default.nix
new file mode 100644
index 0000000..b1a4da3
--- /dev/null
+++ b/sys/ns/default.nix
@@ -0,0 +1,10 @@
+{
+ imports = [
+ ./mx.nix
+ ./ns.nix
+ ./nsd.nix
+ ./ptr
+ ./rr.nix
+ ./zones
+ ];
+}
diff --git a/sys/ns/dkim/README.md b/sys/ns/dkim/README.md
new file mode 100644
index 0000000..37073ba
--- /dev/null
+++ b/sys/ns/dkim/README.md
@@ -0,0 +1 @@
+# This directory has been lustrated.
diff --git a/sys/ns/mx.nix b/sys/ns/mx.nix
new file mode 100644
index 0000000..892b684
--- /dev/null
+++ b/sys/ns/mx.nix
@@ -0,0 +1,60 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ inherit (config.local) domains;
+in {
+ options.local.ns.zones = mkOption {
+ type = with lib.types;
+ attrsOf (submodule ({
+ config,
+ name,
+ ...
+ }: {
+ options.localMX = {
+ enable = mkEnableOption "local MX settings";
+ };
+
+ config = mkIf config.localMX.enable {
+ mx = [
+ {
+ name = "@";
+ priority = 10;
+ host = "${domains.smtp.gated}.";
+ }
+ {
+ name = "@";
+ priority = 20;
+ host = "${domains.smtp-backup.main}.";
+ }
+ # Many thanks to junkemailfilter.com for all their years of service. RIP.
+ #{ name = "@"; priority = 30; host = "mxbackup1.junkemailfilter.com."; }
+ #{ name = "@"; priority = 40; host = "mxbackup2.junkemailfilter.com."; }
+ ];
+
+ txt =
+ [
+ {
+ name = "@";
+ text = "v=spf1 mx a -all";
+ }
+ {
+ name = "_dmarc";
+ text = "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;fo=1;rf=afrf;rua=mailto:postmaster@${name}";
+ }
+ {
+ name = "_adsp._domainkey";
+ text = "dkim=all";
+ }
+ ]
+ ++ map
+ (selector: {
+ name = "${toString selector}._domainkey";
+ text = readFile (./dkim + "/${toString selector}.txt");
+ }) [202001 202102 202402 202408];
+ };
+ }));
+ };
+}
diff --git a/sys/ns/ns.nix b/sys/ns/ns.nix
new file mode 100644
index 0000000..e5b30e8
--- /dev/null
+++ b/sys/ns/ns.nix
@@ -0,0 +1,153 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ inherit (config.networking) domain;
+ inherit (config.local.nets) gate-public;
+ inherit (config.local.ns.server) tsigName;
+
+ ptrNets = config.local.ns.ptr;
+in {
+ options.local.ns.zones = mkOption {
+ type = with lib.types;
+ attrsOf
+ (submodule
+ ({
+ config,
+ name,
+ ...
+ }: let
+ inherit (config.soa) primary;
+
+ cfg = config.localNS;
+ ptrDomain = cfg.ptrNet.v4 != null || cfg.ptrNet.v6 != null;
+ in {
+ options.localNS = {
+ enable = mkEnableOption "local NS settings";
+
+ acme = mkOption {
+ default = {};
+ type = attrsOf str;
+ };
+
+ ptrNet = {
+ v4 = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ v6 = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+ };
+ };
+
+ config =
+ mkIf cfg.enable
+ {
+ ptrName = let
+ name =
+ if cfg.ptrNet.v6 != null
+ then "${cfg.ptrNet.v6}-v6"
+ else "${cfg.ptrNet.v4}-v4";
+ in
+ mkIf ptrDomain name;
+
+ # https://docs.gandi.net/en/domain_names/advanced_users/secondary_nameserver.html
+ nsdConfig = let
+ providerSecondary = [
+ "37.205.15.45 ${tsigName}" # ns3.vpsfree.cz
+ "37.205.11.85 ${tsigName}" # ns4.vpsfree.cz
+ "2a03:3b40:fe:2be::1 ${tsigName}" # ns3.vpsfree.cz
+ "2a03:3b40:101:4::1 ${tsigName}" # ns4.vpsfree.cz
+ ];
+ in {
+ notify = providerSecondary;
+ provideXFR = providerSecondary;
+ };
+
+ ns = [
+ {
+ name = "@";
+ host = primary;
+ }
+ {
+ name = "@";
+ host = "ns3.vpsfree.cz.";
+ }
+ {
+ name = "@";
+ host = "ns4.vpsfree.cz.";
+ }
+ ];
+
+ a =
+ optional (!ptrDomain)
+ {
+ name = primary;
+ ipv4 = gate-public.hosts.gate.v4.address;
+ ptr = null;
+ };
+
+ aaaa =
+ optional (!ptrDomain)
+ {
+ name = primary;
+ ipv6 = gate-public.hosts.gate.v6.address;
+ ptr = null;
+ };
+
+ ptr = let
+ ptrsToRecords = mapAttrsToList (suffix: target: {
+ name = suffix;
+ inherit target;
+ });
+
+ v4Net = cfg.ptrNet.v4;
+ v6Net = cfg.ptrNet.v6;
+
+ v4Records = optionals (v4Net != null) (ptrsToRecords ptrNets.${v4Net}.v4.targets);
+ v6Records = optionals (v6Net != null) (ptrsToRecords ptrNets.${v6Net}.v6.targets);
+ in
+ v4Records ++ v6Records;
+
+ soa = mkIf ptrDomain {
+ authorityZone = mkDefault "${domain}.";
+ };
+
+ cname =
+ mapAttrsToList
+ (name: id: {
+ name = "_acme-challenge" + optionalString (name != "@") ".${name}";
+ target = "${id}.acme-challenge.${domain}.";
+ })
+ cfg.acme;
+ };
+ }));
+ };
+
+ config = {
+ assertions =
+ mapAttrsToList
+ (name: zone: {
+ assertion = zone.localNS.ptrNet.v4 != null -> zone.localNS.ptrNet.v6 == null;
+ message = "zone '${name}' defined as both a v4 and v6 PTR zone";
+ })
+ config.local.ns.zones;
+
+ local.ns.ptr = let
+ zonePtrNets = name: zone:
+ optionalAttrs (zone.localNS.ptrNet.v4 != null)
+ {
+ ${zone.localNS.ptrNet.v4}.v4.zone = name;
+ }
+ // optionalAttrs (zone.localNS.ptrNet.v6 != null) {
+ ${zone.localNS.ptrNet.v6}.v6.zone = name;
+ };
+ in
+ mkMerge (flatten (mapAttrsToList zonePtrNets (filterAttrs (_: zone: zone.localNS.enable) config.local.ns.zones)));
+ };
+}
diff --git a/sys/ns/nsd.nix b/sys/ns/nsd.nix
new file mode 100644
index 0000000..d49e464
--- /dev/null
+++ b/sys/ns/nsd.nix
@@ -0,0 +1,87 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ inherit (config.networking) domain;
+
+ cfg = config.local.ns.server;
+
+ acmeChallengeDomain = "acme-challenge.${domain}";
+in {
+ options. local. ns. server = {
+ enable = mkEnableOption "nsd authoritative server";
+
+ tsigName = mkOption {
+ type = types.str;
+ default = "NOKEY";
+ };
+
+ acme = {
+ apiListen.v6 = mkOption {
+ type = types.str;
+ };
+
+ dnsListen.v6 = mkOption {
+ type = types.str;
+ };
+ };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.tsigName == "NOKEY" || config.services.nsd.keys ? "${cfg.tsigName}";
+ message = "TSIG key '${cfg.tsigName}' not defined";
+ }
+ ];
+
+ networking.firewall = let
+ inherit (config.services.nsd) port;
+ in {
+ allowedTCPPorts = [port];
+ allowedUDPPorts = [port];
+ };
+
+ services = {
+ acme-dns = {
+ enable = true;
+ settings = {
+ api = {
+ ip = "[${cfg.acme.apiListen.v6}]";
+ port = 80;
+ };
+
+ general = {
+ domain = acmeChallengeDomain;
+ nsname = acmeChallengeDomain;
+ nsadmin = "hostmaster.${domain}";
+
+ listen = "[${cfg.acme.dnsListen.v6}]:53";
+
+ records = [
+ "${acmeChallengeDomain}. NS ${acmeChallengeDomain}."
+ "${acmeChallengeDomain}. AAAA ${cfg.acme.dnsListen.v6}"
+ ];
+ };
+ };
+ };
+
+ nsd = {
+ enable = true;
+
+ ipFreebind = true;
+
+ bind8Stats = true;
+ statistics = 3600;
+
+ tcpCount = 128;
+ tcpTimeout = 30;
+ tcpQueryCount = 128;
+
+ zones = mapAttrs' (name: zone: nameValuePair "${name}." zone.nsdConfig) config.local.ns.zones;
+ };
+ };
+ };
+}
diff --git a/sys/ns/ptr/default.nix b/sys/ns/ptr/default.nix
new file mode 100644
index 0000000..b4fba7e
--- /dev/null
+++ b/sys/ns/ptr/default.nix
@@ -0,0 +1,9 @@
+{config, ...}: let
+ inherit (config.local) nets;
+in {
+ config.local.ns.zones = {
+ ${nets.gate-public.v4.ptrDomain} = import ./gate-public-v4;
+ ${nets.gate-public.v6.ptrDomain} = import ./gate-public-v6;
+ ${nets.static-prefix.v6.ptrDomain} = import ./static-prefix-v6;
+ };
+}
diff --git a/sys/ns/ptr/gate-public-v4/default.nix b/sys/ns/ptr/gate-public-v4/default.nix
new file mode 100644
index 0000000..44c7f2e
--- /dev/null
+++ b/sys/ns/ptr/gate-public-v4/default.nix
@@ -0,0 +1,14 @@
+{config, ...}: let
+ inherit (config.local) nets;
+in {
+ imports = [
+ ./serial.nix
+ ];
+
+ config = {
+ localNS = {
+ enable = true;
+ ptrNet.v4 = "gate-public";
+ };
+ };
+}
diff --git a/sys/ns/ptr/gate-public-v4/serial.nix b/sys/ns/ptr/gate-public-v4/serial.nix
new file mode 100644
index 0000000..008e5d8
--- /dev/null
+++ b/sys/ns/ptr/gate-public-v4/serial.nix
@@ -0,0 +1,6 @@
+{
+ config = {
+ soa.serial = 2025042402;
+ nullSerialHash = "sha256-afaedee02017aabd45b944a657ce91515866982c7cb900927edcee6d2b39c731";
+ };
+}
diff --git a/sys/ns/ptr/gate-public-v6/default.nix b/sys/ns/ptr/gate-public-v6/default.nix
new file mode 100644
index 0000000..674421f
--- /dev/null
+++ b/sys/ns/ptr/gate-public-v6/default.nix
@@ -0,0 +1,14 @@
+{config, ...}: let
+ inherit (config.local) nets;
+in {
+ imports = [
+ ./serial.nix
+ ];
+
+ config = {
+ localNS = {
+ enable = true;
+ ptrNet.v6 = "gate-public";
+ };
+ };
+}
diff --git a/sys/ns/ptr/gate-public-v6/serial.nix b/sys/ns/ptr/gate-public-v6/serial.nix
new file mode 100644
index 0000000..126a17e
--- /dev/null
+++ b/sys/ns/ptr/gate-public-v6/serial.nix
@@ -0,0 +1,6 @@
+{
+ config = {
+ soa.serial = 2025042402;
+ nullSerialHash = "sha256-9a8ac8849ea6c8993e44feefe439b96c643e2ccf3a03d0d700558e9a188f57d7";
+ };
+}
diff --git a/sys/ns/ptr/static-prefix-v6/default.nix b/sys/ns/ptr/static-prefix-v6/default.nix
new file mode 100644
index 0000000..7688b97
--- /dev/null
+++ b/sys/ns/ptr/static-prefix-v6/default.nix
@@ -0,0 +1,14 @@
+{config, ...}: let
+ inherit (config.local) nets;
+in {
+ imports = [
+ ./serial.nix
+ ];
+
+ config = {
+ localNS = {
+ enable = true;
+ ptrNet.v6 = "static-prefix";
+ };
+ };
+}
diff --git a/sys/ns/ptr/static-prefix-v6/serial.nix b/sys/ns/ptr/static-prefix-v6/serial.nix
new file mode 100644
index 0000000..a7c214a
--- /dev/null
+++ b/sys/ns/ptr/static-prefix-v6/serial.nix
@@ -0,0 +1,6 @@
+{
+ config = {
+ soa.serial = 2025042600;
+ nullSerialHash = "sha256-a5ce7781b014aa816998410db440dd40278d8b566d1de76e06776a83c9839b35";
+ };
+}
diff --git a/sys/ns/rr.nix b/sys/ns/rr.nix
new file mode 100644
index 0000000..7f089d1
--- /dev/null
+++ b/sys/ns/rr.nix
@@ -0,0 +1,520 @@
+{
+ config,
+ lib,
+ options,
+ pkgs,
+ ...
+}:
+with lib; let
+ inherit (config.local) nets;
+
+ cfg = config.local.ns;
+ globalConfig = config;
+
+ segmentRegex = "[a-z0-9_-]+(\\.[a-z0-9_-]+)*";
+
+ emailType = lib.types.strMatching "[a-z0-9._-]+(@${segmentRegex})?";
+ domainRefType = lib.types.strMatching "@|${segmentRegex}\\.?";
+ domainNameType = lib.types.strMatching "${segmentRegex}\\.";
+
+ zoneHashCheck = name: zone: let
+ zoneHash = algorithm: "${algorithm}-${builtins.hashString algorithm cfg.nullSerialZones.${name}.content}";
+ expected = zoneHash "sha256";
+ in {
+ inherit expected zone;
+ needsUpdate = zone.soa.serial == null || zone.nullSerialHash != expected;
+ };
+
+ rrTypes = [
+ "A"
+ "AAAA"
+ "CNAME"
+ "MX"
+ "NS"
+ "PTR"
+ "SOA"
+ "SRV"
+ "TXT"
+ ];
+in {
+ options.local.ns = {
+ nullSerialZones = mkOption {
+ type = options.local.ns.zones.type;
+ readOnly = true;
+ };
+
+ ptr = mkOption {
+ default = {};
+
+ type = with lib.types;
+ attrsOf (submodule {
+ options = {
+ v4 = {
+ zone = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ targets = mkOption {
+ type = attrsOf str;
+ default = {};
+ };
+ };
+
+ v6 = {
+ zone = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ targets = mkOption {
+ type = attrsOf str;
+ default = {};
+ };
+ };
+ };
+ });
+ };
+
+ zones = mkOption {
+ default = {};
+
+ type = with lib.types;
+ attrsOf (submodule ({
+ config,
+ name,
+ ...
+ }: let
+ nameOption = args @ {
+ defaultZone ? "${name}.",
+ permitRelative ? true,
+ ...
+ }:
+ mkOption (removeAttrs args ["defaultZone" "permitRelative"]
+ // {
+ type = domainRefType;
+
+ apply = value: let
+ zone =
+ throwIfNot
+ (hasSuffix "." defaultZone)
+ "zone expression '${defaultZone}' must be absolute, not relative"
+ defaultZone;
+ in
+ if value == "@"
+ then zone
+ else if hasSuffix "." value
+ then value
+ else if permitRelative
+ then "${value}.${zone}"
+ else throw "zone expression '${value}' in zone '${zone}' must be absolute, not relative";
+ });
+
+ rrType = options:
+ mkOption {
+ default = [];
+ type = listOf (submodule {
+ options =
+ options
+ // {
+ name = nameOption {};
+
+ ttl = mkOption {
+ type = int;
+ default = config.defaultTTL;
+ };
+ };
+ });
+ };
+
+ rrConfig = {
+ rrs,
+ type,
+ format,
+ applyName ? (rr: rr.name),
+ }: (map
+ (rr: {
+ inherit type;
+ inherit (rr) ttl;
+
+ data = format rr;
+ name = applyName rr;
+ })
+ rrs);
+ in {
+ options = {
+ local = mkOption {
+ type = unspecified;
+ default = globalConfig.local;
+ readOnly = true;
+ };
+
+ defaultTTL = mkOption {
+ type = int;
+ default = 3600;
+ };
+
+ ptrName = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ defaultPtr = {
+ v4 = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ v6 = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+ };
+
+ nsdConfig = mkOption {
+ type = attrsOf unspecified;
+ default = {};
+ };
+
+ content = mkOption {
+ type = lines;
+ readOnly = true;
+ };
+
+ nullSerialHash = mkOption {
+ type = nullOr str;
+ default = null;
+ };
+
+ rr = mkOption {
+ default = [];
+ type =
+ listOf
+ (submodule {
+ options = {
+ name = nameOption {};
+
+ ttl = mkOption {
+ type = int;
+ };
+
+ class = mkOption {
+ type = enum ["IN"];
+ default = "IN";
+ };
+
+ type = mkOption {
+ type = enum rrTypes;
+ };
+
+ data = mkOption {
+ type = listOf (either int str);
+ default = [];
+ };
+ };
+ });
+ };
+
+ soa = {
+ authorityZone = nameOption {
+ default = "@";
+ permitRelative = false;
+ };
+
+ ttl = mkOption {
+ type = int;
+ default = config.defaultTTL;
+ };
+
+ primary = nameOption {
+ default = "ns1";
+ defaultZone = config.soa.authorityZone;
+ };
+
+ hostmaster = mkOption {
+ type = emailType;
+ default = "hostmaster";
+
+ apply = address: let
+ split = splitString "@" address;
+
+ user = head split;
+ domain =
+ if length split == 2
+ then head (tail split)
+ else removeSuffix "." config.soa.authorityZone;
+ in
+ if hasSuffix "." address
+ then address
+ else "${replaceStrings ["."] ["\\."] user}.${domain}.";
+ };
+
+ serial = mkOption {
+ type = nullOr int;
+ default = null;
+ };
+
+ refresh = mkOption {
+ type = int;
+ default = 3 * 3600;
+ };
+
+ retry = mkOption {
+ type = int;
+ default = 3600;
+ };
+
+ expire = mkOption {
+ type = int;
+ default = 7 * 24 * 3600;
+ };
+
+ negativeTTL = mkOption {
+ type = int;
+ default = 3600;
+ };
+ };
+
+ a = rrType {
+ ipv4 = mkOption {
+ type = str;
+ };
+
+ ptr = mkOption {
+ type = nullOr str;
+ default = config.defaultPtr.v4;
+ };
+ };
+
+ aaaa = rrType {
+ ipv6 = mkOption {
+ type = str;
+ };
+
+ ptr = mkOption {
+ type = nullOr str;
+ default = config.defaultPtr.v6;
+ };
+ };
+
+ cname = rrType {
+ target = nameOption {};
+ };
+
+ mx = rrType {
+ host = nameOption {};
+
+ priority = mkOption {
+ type = int;
+ };
+ };
+
+ ns = rrType {
+ host = nameOption {};
+ };
+
+ ptr = rrType {
+ target = nameOption {};
+ };
+
+ srv = rrType {
+ host = nameOption {};
+
+ port = mkOption {
+ type = port;
+ };
+
+ priority = mkOption {
+ type = int;
+ };
+
+ proto = mkOption {
+ type = enum ["tcp" "udp"];
+ };
+
+ service = mkOption {
+ type = str;
+ };
+
+ weight = mkOption {
+ type = int;
+ };
+ };
+
+ txt = rrType {
+ text = mkOption {
+ type = strMatching "[^\"\n\\]*\n?";
+ apply = removeSuffix "\n";
+ };
+ };
+ };
+
+ config = {
+ nsdConfig.data = config.content;
+
+ content = let
+ rrLine = rr: concatMapStringsSep " " toString ([rr.name rr.ttl rr.class rr.type] ++ rr.data);
+ in
+ ''
+ $ORIGIN ${name}.
+ $TTL ${toString config.defaultTTL}
+ ''
+ + concatLines (map rrLine config.rr);
+
+ rr = mkMerge [
+ (mkOrder 0 (singleton {
+ inherit (config.soa) ttl;
+
+ name = "${name}.";
+ type = "SOA";
+
+ data = with config.soa; [
+ primary
+ hostmaster
+ (throwIf (serial == null) "No serial defined for zone ${name}" serial)
+ refresh
+ retry
+ expire
+ negativeTTL
+ ];
+ }))
+
+ (mkOrder 1 (rrConfig {
+ rrs = config.ns;
+ type = "NS";
+ format = rr: [rr.host];
+ }))
+
+ (rrConfig {
+ rrs = config.a;
+ type = "A";
+ format = rr: [rr.ipv4];
+ })
+
+ (rrConfig {
+ rrs = config.aaaa;
+ type = "AAAA";
+ format = rr: [rr.ipv6];
+ })
+
+ (rrConfig {
+ rrs = config.cname;
+ type = "CNAME";
+ format = rr: [rr.target];
+ })
+
+ (rrConfig {
+ rrs = config.mx;
+ type = "MX";
+ format = rr: [rr.priority rr.host];
+ })
+
+ (rrConfig {
+ rrs = config.ptr;
+ type = "PTR";
+ format = rr: [rr.target];
+ })
+
+ (rrConfig {
+ rrs = config.srv;
+ type = "SRV";
+
+ format = rr: [rr.priority rr.weight rr.port rr.host];
+ applyName = rr: "_${rr.service}._${rr.proto}.${rr.name}";
+ })
+
+ (rrConfig {
+ rrs = config.txt;
+ type = "TXT";
+
+ format = rr: let
+ # nsd-zonecheck: text string is longer than 255 characters, try splitting it into multiple parts
+ txtFragments = text: let
+ max = 255;
+ length = stringLength text;
+ in
+ singleton (substring 0 max text) ++ optionals (length > max) (txtFragments (substring max length text));
+ in
+ map (fragment: "\"${fragment}\"") (txtFragments rr.text);
+ })
+ ];
+ };
+ }));
+ };
+ };
+
+ config = {
+ assertions =
+ [
+ (
+ let
+ badZones = attrNames (filterAttrs (name: zone: (zoneHashCheck name zone).needsUpdate) cfg.zones);
+ in {
+ assertion = badZones == [];
+ message = "Update serials for these zones (null-serial hash mismatch): ${concatStringsSep ", " badZones}";
+ }
+ )
+ ]
+ ++ flatten (mapAttrsToList
+ (name: ptr: [
+ {
+ assertion = ptr.v4.targets != {} -> ptr.v4.zone != null;
+ message = "undefined v4 PTR net '${name}': ${concatStringsSep ", " (attrValues ptr.v4.targets)}";
+ }
+ {
+ assertion = ptr.v6.targets != {} -> ptr.v6.zone != null;
+ message = "undefined v6 PTR net '${name}': ${concatStringsSep ", " (attrValues ptr.v6.targets)}";
+ }
+ ])
+ cfg.ptr);
+
+ lib.local.zoneSerialUpdates = let
+ ptrChecks = filterAttrs (_: check: check.zone.ptrName != null) allZoneChecks;
+ zoneChecks = filterAttrs (_: check: check.zone.ptrName == null) allZoneChecks;
+ allZoneChecks = filterAttrs (_: check: check.needsUpdate) (mapAttrs zoneHashCheck cfg.zones);
+
+ updateInfo = name: check: {
+ inherit name;
+ inherit (check) expected;
+ inherit (check.zone.soa) serial;
+ };
+ in {
+ ptr = mapAttrs (_: check: updateInfo check.zone.ptrName check) ptrChecks;
+ zones = mapAttrs updateInfo zoneChecks;
+ };
+
+ local.ns = {
+ nullSerialZones = let
+ defaultAttrs = ["defaultTTL" "defaultPtr" "ptrName"];
+ filteredAttrs = defaultAttrs ++ map toLower rrTypes;
+ in
+ mapAttrs
+ (_: zone:
+ mkMerge [
+ (filterAttrs (name: _: elem name filteredAttrs) zone)
+ {soa.serial = mkOverride 0 0;}
+ ])
+ cfg.zones;
+
+ ptr = let
+ zonePtrs = zone: let
+ v4Ptrs =
+ map
+ (a: {
+ ${a.ptr}.v4.targets.${nets.${a.ptr}.v4.ptrRecordName a.ipv4 32} = a.name;
+ })
+ (filter (a: a.ptr != null) zone.a);
+
+ v6Ptrs =
+ map
+ (aaaa: {
+ ${aaaa.ptr}.v6.targets.${nets.${aaaa.ptr}.v6.ptrRecordName aaaa.ipv6 128} = aaaa.name;
+ })
+ (filter (aaaa: aaaa.ptr != null) zone.aaaa);
+ in
+ v4Ptrs ++ v6Ptrs;
+ in
+ mkMerge (flatten (mapAttrsToList (_: zonePtrs) cfg.zones));
+ };
+ };
+}
diff --git a/sys/ns/zones/README.md b/sys/ns/zones/README.md
new file mode 100644
index 0000000..37073ba
--- /dev/null
+++ b/sys/ns/zones/README.md
@@ -0,0 +1 @@
+# This directory has been lustrated.
diff --git a/sys/nspawn/default.nix b/sys/nspawn/default.nix
new file mode 100644
index 0000000..15e60de
--- /dev/null
+++ b/sys/nspawn/default.nix
@@ -0,0 +1,5 @@
+{
+ imports = [
+ ./dmz.nix
+ ];
+}
diff --git a/sys/nspawn/dmz.nix b/sys/nspawn/dmz.nix
new file mode 100644
index 0000000..3263730
--- /dev/null
+++ b/sys/nspawn/dmz.nix
@@ -0,0 +1,229 @@
+{
+ config,
+ lib,
+ pkgs,
+ flakes,
+ doctrine,
+ ...
+}:
+with lib; let
+ cfg = config.local.nspawn.dmz;
+ inherit (config.local) mailHost;
+
+ dmzNet = config.local.nets.${cfg.netName};
+
+ hassPort = config.services.home-assistant.config.http.server_port;
+ hassEnable = config.local.home-assistant.enable;
+in {
+ options.local.nspawn.dmz = {
+ enable = mkEnableOption "DMZ services in a container";
+
+ dns64 = mkOption {
+ type = types.str;
+ };
+
+ netName = mkOption {
+ type = types.str;
+ };
+
+ net6 = mkOption {
+ type = types.str;
+ readOnly = true;
+ };
+
+ hostAddr6 = mkOption {
+ type = types.str;
+ readOnly = true;
+ };
+
+ system = mkOption {
+ type = types.raw;
+ };
+ };
+
+ # Situación con os-release
+ #
+ # La idea aquí es poder hacer 'btrfs subvol create /var/lib/machines/foo' y
+ # dejar que systemd-nspawn y el activation script creen todo lo demás. Esto
+ # no sirve bien debido a la prueba barata que hace systemd para revisar si el
+ # árbol parece contener una imagen de sistema operativo. Esta prueba falla en
+ # dos momentos distintos:
+ #
+ # 1. Inmediatamente tras crear un árbol vacío, puesto que os-release no existe.
+ # La solución naive es 'mkdir rootfs/etc && touch rootfs/etc/os-release'.
+ #
+ # 2. Luego de reiniciar el contenedor una vez que NixOS ha preparado /etc, ya que
+ # systemd espera un archivo regular y no telera el symlink a la store.
+ #
+ # Resulta ser que systemd revisa tanto /etc/os-release como /usr/lib/os-release.
+ # NixOS evidentemente no usa la segunda ruta por ser FHS, así que la duct tape
+ # final es 'mkdir rootfs/usr/lib && touch rootfs/usr/lib/os-release'.
+
+ config = mkIf cfg.enable {
+ local = {
+ mailHost.mdaListen = cfg.hostAddr6;
+
+ nspawn.dmz = {
+ hostAddr6 = dmzNet.hosts.gateway.v6.address;
+
+ system = let
+ containerModule = {...}: {
+ #TODO: urgente: bloquear puertos de dovecot a non-postfix con iptables
+ config = {
+ local = {
+ preset.dmz = {
+ enable = true;
+ container = true;
+ };
+
+ mta = {
+ mdaAddr = "[${mailHost.mdaListen}]";
+ inherit (mailHost) saslPort lmtpPort;
+ };
+
+ web.sites = {
+ home = {
+ enable = hassEnable;
+ proxyUrl = "http://[${cfg.hostAddr6}]:${toString hassPort}";
+ };
+ };
+ };
+
+ nixpkgs = {
+ pkgs = mkDefault pkgs;
+ localSystem = mkDefault pkgs.stdenv.hostPlatform;
+ };
+
+ services.nginx.virtualHosts = {
+ "${config.local.domains.imap.main}".locations."^~ /.well-known/acme-challenge/" = {
+ root = "/var/lib/acme/acme-challenge";
+
+ extraConfig = ''
+ auth_basic off;
+ auth_request off;
+ '';
+ };
+ };
+
+ systemd.network.networks."40-host0" = {
+ name = "host0";
+
+ networkConfig = {
+ DNS = [cfg.dns64];
+
+ DHCP = "no";
+ IPv6AcceptRA = "yes";
+ LinkLocalAddressing = "ipv6";
+ };
+
+ ipv6AcceptRAConfig = {
+ Token = [
+ "static:::${dmzNet.hosts.dmz.v6.suffix}"
+ "eui64"
+ "static:::${dmzNet.hosts.mta.v6.suffix}"
+ "static:::${dmzNet.hosts.web.v6.suffix}"
+ ];
+
+ UseDNS = false;
+ };
+ };
+ };
+ };
+ in
+ flakes.trivionomicon.lib.mkSystem {
+ inherit doctrine flakes pkgs;
+
+ modules = [
+ ../.
+ containerModule
+ ];
+ };
+ };
+ };
+
+ services = {
+ home-assistant.config.http = mkIf hassEnable {
+ server_host = [cfg.hostAddr6];
+ trusted_proxies = [dmzNet.hosts.web.v6.address];
+ use_x_forwarded_for = true;
+ };
+ };
+
+ systemd = {
+ nspawn.dmz = {
+ execConfig.PrivateUsers = "pick";
+
+ filesConfig.BindReadOnly = [
+ # idmap porque algunos hacks en nixpkgs (postfix-setup.service)
+ # asumen que la store es de root
+ "/nix/store:/nix/store:idmap"
+ "${cfg.system.config.system.build.toplevel}/init:/sbin/init"
+ ];
+ };
+
+ network.networks."40-ve-dmz" = {
+ matchConfig = {
+ Name = "ve-dmz";
+ Driver = "veth";
+ };
+
+ addresses = [
+ {
+ Address = dmzNet.hosts.gateway.v6.cidr;
+ AddPrefixRoute = "no";
+ PreferredLifetime = 0;
+ }
+ ];
+
+ networkConfig = {
+ LinkLocalAddressing = "ipv6";
+ DHCPServer = "no";
+ IPMasquerade = "no";
+ LLDP = "no";
+ EmitLLDP = "no";
+ IPv6SendRA = "yes";
+ IPv6AcceptRA = "no";
+ };
+
+ ipv6Prefixes = [
+ {
+ Assign = "no";
+ Prefix = dmzNet.v6.cidr;
+ }
+ ];
+
+ routes = [
+ {
+ Destination = dmzNet.v6.cidr;
+ # Sin esto, siempre se escogerá una ULA como source address debido a "PreferredLifetime = 0" en la GUA
+ PreferredSource = dmzNet.hosts.gateway.v6.address;
+ }
+ ];
+ };
+
+ services = {
+ dovecot2.after = ["systemd-nspawn@dmz.service"];
+
+ "systemd-nspawn@dmz" = {
+ overrideStrategy = "asDropin";
+
+ after = ["network-online.target"];
+ wants = ["network-online.target"];
+ wantedBy = ["machines.target"];
+ };
+ };
+ };
+
+ networking.firewall = {
+ allowedTCPPorts = [25 80 443];
+
+ interfaces.ve-dmz = {
+ allowedTCPPorts =
+ [mailHost.saslPort mailHost.lmtpPort]
+ ++ optional hassEnable hassPort;
+
+ allowedUDPPorts = [67]; # DHCP
+ };
+ };
+ };
+}
diff --git a/sys/platform/README.md b/sys/platform/README.md
new file mode 100644
index 0000000..37073ba
--- /dev/null
+++ b/sys/platform/README.md
@@ -0,0 +1 @@
+# This directory has been lustrated.
diff --git a/sys/preset/default.nix b/sys/preset/default.nix
new file mode 100644
index 0000000..45ae529
--- /dev/null
+++ b/sys/preset/default.nix
@@ -0,0 +1,6 @@
+{
+ imports = [
+ ./dmz.nix
+ ./user.nix
+ ];
+}
diff --git a/sys/preset/dmz.nix b/sys/preset/dmz.nix
new file mode 100644
index 0000000..5a04c1e
--- /dev/null
+++ b/sys/preset/dmz.nix
@@ -0,0 +1,64 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.preset.dmz;
+in {
+ options.local.preset.dmz = {
+ enable = mkEnableOption "dmz preset";
+
+ container = mkOption {
+ type = types.bool;
+ default = false;
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ local = {
+ boot = {
+ kernel = mkDefault pkgs.linuxPackages_hardened;
+ loader = mkDefault "grub";
+
+ efi.enable = mkDefault (!cfg.container);
+ firmware.mode = mkDefault "none";
+ namespaced.enable = cfg.container;
+
+ stack.luksExt4FscryptImpermanence = {
+ enable = mkDefault (!cfg.container);
+ };
+ };
+
+ jobs.pkiExpiry.enable = mkDefault config.local.mta.enable;
+
+ mta = {
+ enable = mkDefault true;
+
+ mode = "primary";
+ };
+
+ net = {
+ enable = true;
+ hostname = "dmz";
+
+ fail2ban.enable = true;
+ };
+
+ web.sites.portal.enable = true;
+ };
+
+ services = {
+ resolved = {
+ llmnr = "false";
+ fallbackDns = []; # Disable the default systemd-resolved server list
+ };
+ };
+
+ users = {
+ allowNoPasswordLogin = cfg.container;
+ mutableUsers = false;
+ };
+ };
+}
diff --git a/sys/preset/user.nix b/sys/preset/user.nix
new file mode 100644
index 0000000..fd9c5ff
--- /dev/null
+++ b/sys/preset/user.nix
@@ -0,0 +1,73 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}: let
+ inherit (lib) mkDefault;
+ cfg = config.local.preset.user;
+in {
+ options.local.preset.user = {
+ enable = lib.mkEnableOption "user-like preset";
+ };
+
+ config = lib.mkIf cfg.enable {
+ local = {
+ installUsers = mkDefault "single";
+
+ auth = {
+ oath.enable = mkDefault true;
+
+ openssh = {
+ enable = mkDefault true;
+
+ hostKeys = {
+ rsa = mkDefault true;
+ ecdsa = mkDefault true;
+ ed25519 = mkDefault true;
+ };
+ };
+ };
+
+ boot = {
+ kernel = mkDefault pkgs.linuxPackages_latest;
+ loader = mkDefault "grub";
+
+ efi = {
+ enable = mkDefault true;
+ removable = mkDefault false;
+ };
+
+ firmware.mode = mkDefault "redistributable";
+ detachedLuks.enable = mkDefault true;
+
+ stack.btrfsToplevelMultidrive = {
+ enable = mkDefault true;
+
+ toplevel.root = mkDefault "/root";
+ secondary.home = mkDefault "/home";
+ };
+ };
+
+ hardware = {
+ yubico.enable = mkDefault true;
+ bluetooth.enable = mkDefault true;
+ };
+
+ net.enable = true;
+
+ seat = {
+ enable = true;
+ graphical = mkDefault true;
+ };
+ };
+
+ services.nullmailer = {
+ enable = mkDefault true;
+
+ config = {
+ me = "${config.networking.hostName}@${config.networking.domain}";
+ };
+ };
+ };
+}
diff --git a/sys/seat/default.nix b/sys/seat/default.nix
new file mode 100644
index 0000000..402047f
--- /dev/null
+++ b/sys/seat/default.nix
@@ -0,0 +1,142 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.seat;
+
+ users = filterAttrs (_: user: user.install) config.local.users;
+in {
+ options.local.seat = {
+ enable = mkEnableOption "user seat";
+
+ graphical = mkOption {
+ type = types.bool;
+ default = false;
+ };
+
+ wayland = mkOption {
+ type = types.bool;
+ default = true;
+ };
+
+ videoDrivers = mkOption {
+ type = with types; listOf str;
+ };
+ };
+
+ config =
+ mkIf cfg.enable
+ (mkMerge [
+ {
+ hardware = {
+ acpilight.enable = true;
+ };
+
+ security.rtkit.enable = true;
+
+ services = {
+ pipewire = {
+ enable = true;
+
+ alsa = {
+ enable = true;
+ support32Bit = true;
+ };
+
+ jack.enable = true;
+ pulse.enable = true;
+ wireplumber.enable = true;
+ };
+
+ pulseaudio.enable = false;
+ };
+
+ users = {
+ groups =
+ mapAttrs (_: user: {inherit (user) gid;}) users
+ // {
+ adbusers.gid = 1008;
+ };
+
+ users =
+ mapAttrs
+ (username: user: {
+ isNormalUser = true;
+
+ inherit (user) uid;
+ description = user.gecos;
+
+ group = username;
+ extraGroups = ["users"] ++ user.groups;
+
+ shell =
+ if user.allowLogin
+ then pkgs.zsh
+ else null;
+ })
+ users;
+ };
+ }
+ (mkIf cfg.graphical {
+ environment = {
+ sessionVariables.NIXOS_OZONE_WL = "1";
+
+ systemPackages = with pkgs; [
+ qt5.qtwayland
+ qt6.qtwayland
+ ];
+ };
+
+ hardware.graphics.enable = true;
+
+ programs = {
+ dconf.enable = true;
+
+ gtklock = {
+ enable = true;
+
+ config = {};
+ modules = [];
+ };
+ };
+
+ services = {
+ libinput.enable = true;
+
+ udev.packages = with pkgs; [
+ pkgs.android-udev-rules
+ ];
+
+ xserver = mkIf (!cfg.wayland) {
+ enable = true;
+ videoDrivers = cfg.videoDrivers ++ ["modesetting" "fbdev"];
+ displayManager.startx.enable = mkDefault true;
+ };
+ };
+
+ xdg.portal = {
+ enable = true;
+ wlr.enable = true;
+ extraPortals = [pkgs.xdg-desktop-portal-gtk];
+ xdgOpenUsePortal = true;
+
+ # warning: xdg-desktop-portal 1.17 reworked how portal implementations are loaded, you
+ # should either set `xdg.portal.config` or `xdg.portal.configPackages`
+ # to specify which portal backend to use for the requested interface.
+ #
+ # https://github.com/flatpak/xdg-desktop-portal/blob/1.18.1/doc/portals.conf.rst.in
+ #
+ # If you simply want to keep the behaviour in < 1.17, which uses the first
+ # portal implementation found in lexicographical order, use the following:
+ #
+ # xdg.portal.config.common.default = "*";
+ config.common.default = "*";
+ };
+
+ users.groups.adbusers.gid = 1008;
+ })
+ ]);
+}
diff --git a/sys/syncthing/default.nix b/sys/syncthing/default.nix
new file mode 100644
index 0000000..951ad30
--- /dev/null
+++ b/sys/syncthing/default.nix
@@ -0,0 +1,45 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.syncthing;
+in {
+ options.local.syncthing = {
+ enable = mkEnableOption "syncthing server";
+ openFirewall = mkEnableOption "syncthing firewall rules";
+ };
+
+ config = mkMerge [
+ {
+ networking.firewall = {
+ allowedTCPPorts = optional cfg.openFirewall 22000;
+ allowedUDPPorts = optional cfg.openFirewall 22000;
+ };
+ }
+ (mkIf cfg.enable {
+ local.syncthing.openFirewall = true;
+
+ services.syncthing = {
+ enable = true;
+
+ systemService = true;
+ overrideFolders = false;
+ overrideDevices = false;
+ openDefaultPorts = true;
+
+ guiAddress = "127.0.0.1:8384";
+
+ settings.options.urAccepted = -1;
+
+ relay = {
+ enable = true;
+
+ pools = [];
+ providedBy = "${config.networking.hostName}.${config.networking.domain}";
+ };
+ };
+ })
+ ];
+}
diff --git a/sys/virt/default.nix b/sys/virt/default.nix
new file mode 100644
index 0000000..1434bad
--- /dev/null
+++ b/sys/virt/default.nix
@@ -0,0 +1,5 @@
+{
+ imports = [
+ ./libvirt.nix
+ ];
+}
diff --git a/sys/virt/dom/README.md b/sys/virt/dom/README.md
new file mode 100644
index 0000000..37073ba
--- /dev/null
+++ b/sys/virt/dom/README.md
@@ -0,0 +1 @@
+# This directory has been lustrated.
diff --git a/sys/virt/libvirt.nix b/sys/virt/libvirt.nix
new file mode 100644
index 0000000..ebbfbcd
--- /dev/null
+++ b/sys/virt/libvirt.nix
@@ -0,0 +1,64 @@
+{
+ config,
+ flakes,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.local.virt;
+
+ inherit (config.lib.local) importAll;
+
+ doms = mapAttrs (_: dom: dom {inherit config lib pkgs;}) (importAll {root = ./dom;});
+in {
+ options.local.virt = {
+ enable = mkEnableOption "hypervisor support";
+
+ dom =
+ mapAttrs
+ (name: _: {
+ enable = mkEnableOption "domain ${name}";
+ })
+ doms;
+ };
+
+ config = mkIf cfg.enable {
+ local.boot.impermanence.directories = [
+ {
+ directory = "/var/dom";
+ user = "root";
+ group = "qemu-libvirtd";
+ mode = "u=rwx,g=rx,o=";
+ }
+ ];
+
+ virtualisation = {
+ libvirt = {
+ enable = any (dom: dom.enable) (attrValues cfg.dom);
+
+ connections."qemu:///system".domains = let
+ makeDomain = def: {
+ active = true;
+ restart = false;
+ definition = flakes.nixvirt.lib.domain.writeXML def;
+ };
+ in
+ map makeDomain (attrValues (filterAttrs (name: _: cfg.dom.${name}.enable) doms));
+
+ swtpm.enable = true;
+ };
+
+ libvirtd = {
+ enable = true;
+
+ qemu = {
+ runAsRoot = false;
+
+ ovmf.enable = true;
+ swtpm.enable = true;
+ };
+ };
+ };
+ };
+}
diff --git a/sys/web/default.nix b/sys/web/default.nix
new file mode 100644
index 0000000..2fc769f
--- /dev/null
+++ b/sys/web/default.nix
@@ -0,0 +1,7 @@
+{
+ imports = [
+ ./nginx.nix
+ ./php-fpm.nix
+ ./sites
+ ];
+}
diff --git a/sys/web/nginx.nix b/sys/web/nginx.nix
new file mode 100644
index 0000000..a054289
--- /dev/null
+++ b/sys/web/nginx.nix
@@ -0,0 +1,94 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.web;
+ inherit (config.local) domains;
+in {
+ options.local.web = {
+ enable = mkEnableOption "web server";
+
+ defaultACMEHost = mkOption {
+ type = types.str;
+ };
+
+ ownedCerts = mkOption {
+ type = with lib.types; listOf str;
+ default = [];
+ };
+ };
+
+ config = mkIf cfg.enable {
+ services = {
+ fail2ban.jails = {
+ # https://discourse.nixos.org/t/fail2ban-with-nginx-and-authelia/31419
+ nginx-botsearch.settings = {
+ # Usar log en vez de journalctl
+ # TODO: Pasar todo a systemd?
+ backend = "pyinotify";
+ logpath = "/var/log/nginx/*.log";
+ journalmatch = "";
+ };
+
+ nginx-bad-request.settings = {
+ backend = "pyinotify";
+ logpath = "/var/log/nginx/*.log";
+ journalmatch = "";
+
+ maxretry = 10;
+ };
+ };
+
+ nginx = {
+ enable = true;
+
+ recommendedGzipSettings = true;
+ recommendedOptimisation = true;
+ recommendedProxySettings = true;
+ recommendedTlsSettings = true;
+
+ logError = "/var/log/nginx/error.log";
+ sslDhparam = config.security.dhparams.params.nginx.path;
+ clientMaxBodySize = "42M";
+
+ mapHashBucketSize = 128;
+
+ virtualHosts.default = {
+ default = true;
+
+ addSSL = true;
+ useACMEHost = cfg.defaultACMEHost;
+
+ locations."/".extraConfig = ''
+ return 403;
+ '';
+ };
+ };
+ };
+
+ local.certs = listToAttrs (map
+ (name: {
+ inherit name;
+ value.enable = true;
+ })
+ cfg.ownedCerts);
+
+ networking.firewall.allowedTCPPorts = [80 443];
+
+ security = {
+ acme.certs = listToAttrs (map
+ (name: {
+ name = domains.${name}.main;
+ value = {
+ group = mkDefault config.services.nginx.group;
+ reloadServices = ["nginx.service"];
+ };
+ })
+ cfg.ownedCerts);
+
+ dhparams.params.nginx = {};
+ };
+ };
+}
diff --git a/sys/web/php-fpm.nix b/sys/web/php-fpm.nix
new file mode 100644
index 0000000..33efe1a
--- /dev/null
+++ b/sys/web/php-fpm.nix
@@ -0,0 +1,154 @@
+# Based on <https://gist.github.com/aanderse/3344baef2c3b86c8a1e98e63bd9256ea>
+# See also:
+# - <https://albert.cx/20181125-use-separate-systemd-units-for-php-fpm-pools>
+# - <https://freedesktop.org/wiki/Software/systemd/DaemonSocketActivation/>
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.services.php-fpm-isolated;
+
+ configFile = {
+ pool,
+ poolOpts,
+ runtimeDir,
+ sockFile,
+ pidFile,
+ }: let
+ config = {
+ global = {
+ daemonize = false;
+ error_log = "syslog";
+ pid = pidFile;
+ };
+
+ "${pool}" = let
+ enforced = {
+ inherit (poolOpts) user group;
+ listen = sockFile;
+ };
+
+ defaults = {
+ "pm" = "dynamic";
+ "pm.max_children" = 16;
+ "pm.min_spare_servers" = 1;
+ "pm.max_spare_servers" = 4;
+ "pm.start_servers" = 1;
+ "catch_workers_output" = true;
+ "php_admin_flag[log_errors]" = true;
+ "env[PATH]" = makeBinPath [pkgs.php];
+ };
+
+ env =
+ mapAttrs'
+ (name: value: {
+ name = "env[${name}]";
+ value = "\"${escape ["\""] value}\"";
+ })
+ poolOpts.env;
+ in
+ defaults // poolOpts.config // env // enforced;
+ };
+ in
+ (pkgs.formats.ini {}).generate "php-fpm-pool-${pool}.conf" config;
+in {
+ options.services.php-fpm-isolated.pools = mkOption {
+ default = {};
+
+ type = with types;
+ attrsOf (submodule {
+ options = {
+ enable = mkEnableOption "PHP-FPM pool";
+
+ user = mkOption {
+ type = str;
+ };
+
+ group = mkOption {
+ type = str;
+ };
+
+ unveil = mkOption {
+ type = listOf (either package str);
+ };
+
+ env = mkOption {
+ type = attrsOf str;
+ default = {};
+ };
+
+ config = mkOption {
+ type = attrsOf (oneOf [int str bool]);
+ default = {};
+ };
+ };
+ });
+ };
+
+ config.systemd = let
+ php-fpm = "${pkgs.php}/bin/php-fpm";
+
+ unitsFor = pool: poolOpts: let
+ runtimeBase = "php-fpm-isolated/${pool}";
+ runtimeDir = "/run/${runtimeBase}";
+ pidFile = "${runtimeDir}/${pool}.pid";
+ sockFile = "${runtimeDir}/${pool}.sock";
+ in {
+ name = "php-fpm-pool-${pool}";
+
+ value.service = {
+ description = "PHP-FPM process manager for pool '${pool}'";
+ after = ["network.target"];
+
+ confinement.enable = true;
+
+ serviceConfig = {
+ Type = "notify";
+ ExecReload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
+ PIDFile = pidFile;
+
+ Environment = "FPM_SOCKETS=${sockFile}=3";
+
+ ExecStart = let
+ fpmConfig = configFile {
+ inherit pool poolOpts runtimeDir sockFile pidFile;
+ };
+ in "${php-fpm} --nodaemonize --fpm-config ${fpmConfig} --pid ${pidFile}";
+
+ PrivateTmp = true;
+ PrivateNetwork = true;
+ PrivateDevices = true;
+ # XXX: We need AF_NETLINK to make the sendmail SUID binary from postfix work
+ RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
+
+ User = poolOpts.user;
+ Group = poolOpts.group;
+ RuntimeDirectory = runtimeBase;
+
+ BindReadOnlyPaths = let
+ unveiled = map builtins.toString poolOpts.unveil;
+ in
+ ["/run/systemd/journal/socket"] ++ unveiled;
+ };
+ };
+
+ value.socket = {
+ description = "PHP-FPM socket for pool '${pool}'";
+ listenStreams = [sockFile];
+
+ socketConfig = {
+ User = poolOpts.user;
+ Group = poolOpts.group;
+ };
+ };
+ };
+
+ units = mapAttrs' unitsFor (filterAttrs (_: pool: pool.enable) cfg.pools);
+ in {
+ sockets = mapAttrs (_: unit: unit.socket) units;
+ services = mapAttrs (_: unit: unit.service) units;
+ };
+}
diff --git a/sys/web/sites/default.nix b/sys/web/sites/default.nix
new file mode 100644
index 0000000..ba2835c
--- /dev/null
+++ b/sys/web/sites/default.nix
@@ -0,0 +1,7 @@
+{
+ imports = [
+ ./home.nix
+ ./host.nix
+ ./portal.nix
+ ];
+}
diff --git a/sys/web/sites/home.nix b/sys/web/sites/home.nix
new file mode 100644
index 0000000..fed9b84
--- /dev/null
+++ b/sys/web/sites/home.nix
@@ -0,0 +1,38 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.web.sites.home;
+ inherit (config.local) domains;
+in {
+ options.local.web.sites.home = {
+ enable = mkEnableOption "home site";
+
+ proxyUrl = mkOption {
+ type = types.str;
+ };
+ };
+
+ config = mkIf cfg.enable {
+ local.web = {
+ enable = mkDefault true;
+ ownedCerts = ["home"];
+ };
+
+ services.nginx.virtualHosts.${domains.home.main} = {
+ forceSSL = true;
+ useACMEHost = domains.home.main;
+
+ locations."/".extraConfig = ''
+ proxy_pass ${cfg.proxyUrl};
+ proxy_redirect http:// https://;
+
+ # Necesario debido a websockets
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ '';
+ };
+ };
+}
diff --git a/sys/web/sites/host.nix b/sys/web/sites/host.nix
new file mode 100644
index 0000000..ea6cc23
--- /dev/null
+++ b/sys/web/sites/host.nix
@@ -0,0 +1,106 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.web.sites.host;
+
+ inherit (config.local) domains;
+ inherit (config.local.net) hostname;
+
+ users = filterAttrs (_: user: user.install) config.local.users;
+ hostDomain = domains.${hostDomainName};
+ hostDomainName = "host-${hostname}";
+
+ userCerts = flatten (flatten (mapAttrsToList
+ (name: user:
+ map
+ (cert: {
+ fprint = config.local.pki.byPath.${cert}.fingerprint.sha1-lower;
+ inherit name;
+ })
+ user.mail.certs)
+ users));
+in {
+ options.local.web.sites.host = {
+ enable = mkEnableOption "host site, restricted to per-user client certs";
+ };
+
+ config = mkIf cfg.enable {
+ local.web = {
+ enable = mkDefault true;
+ ownedCerts = [hostDomainName];
+ };
+
+ services = {
+ nginx = {
+ appendHttpConfig = ''
+ map $ssl_client_fingerprint $host_user_from_fprint {
+ default "";
+ ${concatMapStringsSep "\n " (pair: "\"${escapeRegex pair.fprint}\" \"${pair.name}\";") userCerts}
+ }
+ '';
+
+ virtualHosts = {
+ ${hostDomain.main} = {
+ forceSSL = true;
+ useACMEHost = hostDomain.main;
+
+ extraConfig = ''
+ ssl_verify_depth 2;
+ ssl_verify_client optional;
+ ssl_client_certificate ${config.local.pki.ca.mail.fullchain};
+
+ #if ($ssl_client_verify != "SUCCESS") {
+ #return 403;
+ #}
+ '';
+
+ locations =
+ {
+ "/".return = 403;
+ }
+ // concatMapAttrs
+ (name: user: let
+ userLocation = config: {
+ extraConfig =
+ ''
+ if ($host_user_from_fprint != "${name}") {
+ return 403;
+ }
+ ''
+ + config;
+ };
+
+ userLocations =
+ {
+ "/${name}" = ''
+ return 404;
+ '';
+ }
+ // optionalAttrs user.mail.dav {
+ "/${name}/dav" = ''
+ proxy_pass http://unix:/run/host-www/${name}/dav.sock;
+ '';
+ };
+ in
+ mapAttrs (_: userLocation) userLocations)
+ (filterAttrs (_: user: user.mail.certs != []) users);
+ };
+ };
+ };
+ };
+
+ systemd.tmpfiles.settings."10-run-host-www" =
+ concatMapAttrs
+ (name: _: {
+ "/run/host-www/${name}".d = {
+ mode = "0750";
+ user = name;
+ group = "nginx";
+ };
+ })
+ users;
+ };
+}
diff --git a/sys/web/sites/portal.nix b/sys/web/sites/portal.nix
new file mode 100644
index 0000000..4b5f704
--- /dev/null
+++ b/sys/web/sites/portal.nix
@@ -0,0 +1,37 @@
+{
+ config,
+ lib,
+ ...
+}:
+with lib; let
+ cfg = config.local.web.sites.portal;
+ inherit (config.local) domains;
+in {
+ options.local.web.sites.portal = {
+ enable = mkEnableOption "public non-fqdn portal";
+ };
+
+ config = mkIf cfg.enable {
+ local.web = {
+ enable = mkDefault true;
+ ownedCerts = ["host" "sysret"];
+ defaultACMEHost = domains.host.main;
+ };
+
+ services.nginx.virtualHosts = {
+ ${domains.host.www} = {
+ forceSSL = true;
+ useACMEHost = domains.host.main;
+ serverAliases = [domains.host.main];
+ };
+
+ ${domains.sysret.main} = {
+ forceSSL = true;
+ useACMEHost = domains.sysret.main;
+ serverAliases = [domains.sysret.www];
+
+ locations."/fsociety".return = "301 https://meet.posixlycorrect.com/%C6%92%C6%A8%C5%8F%C4%8B%D3%80%C9%99%CF%AE%D0%A3";
+ };
+ };
+ };
+}