diff options
Diffstat (limited to 'sys')
87 files changed, 5363 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..d278dc0 --- /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..238fc1d --- /dev/null +++ b/sys/baseline/default.nix @@ -0,0 +1,100 @@ +{ + 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 + openssl + ] + ++ optionals (!config.boot.isContainer) [ + alsa-utils + 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" + "/var/trust" + ]; + + nix = { + package = pkgs.nix; + + channel.enable = false; + + extraOptions = '' + experimental-features = nix-command flakes + ''; + + gc = { + dates = "quarterly"; + 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..b6a9ab2 --- /dev/null +++ b/sys/boot/chain.nix @@ -0,0 +1,47 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.local.boot; +in { + options.local.boot = { + loader = mkOption { + type = types.enum ["none" "out-of-band" "grub" "systemd-boot"]; + }; + + kernel = mkOption { + type = types.raw; + }; + }; + + config = mkIf (cfg.loader != "none") { + boot = { + kernelPackages = cfg.kernel; + + loader = + if cfg.loader == "none" || cfg.loader == "out-of-band" + then { + grub.enable = false; + } + else if cfg.loader == "grub" + then { + grub = { + enable = true; + device = "nodev"; + efiSupport = true; + }; + } + else if cfg.loader == "systemd-boot" + then { + systemd-boot = { + enable = true; + editor = true; + }; + } + else throw "unexpected config.local.boot.loader setting: ${cfg.loader}"; + }; + }; +} diff --git a/sys/boot/default.nix b/sys/boot/default.nix new file mode 100644 index 0000000..3cbbb6f --- /dev/null +++ b/sys/boot/default.nix @@ -0,0 +1,15 @@ +{ + imports = [ + ./chain.nix + ./detached-luks.nix + ./efi.nix + ./firmware.nix + ./fscrypt.nix + ./impermanence.nix + ./namespaced.nix + ./pxe.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/pxe.nix b/sys/boot/pxe.nix new file mode 100644 index 0000000..e25ba9d --- /dev/null +++ b/sys/boot/pxe.nix @@ -0,0 +1,187 @@ +{ + config, + lib, + ... +}: +with lib; let + cfg = config.local.boot.pxe; +in { + options.local.boot.pxe = { + enable = mkEnableOption "PXE netboot"; + + setupMode = mkOption { + type = types.bool; + default = false; + }; + + initrdInterface = mkOption { + type = types.str; + }; + + linkLocal6 = mkOption { + type = types.str; + }; + + networkDriver = mkOption { + type = types.str; + }; + + mac = mkOption { + type = types.str; + }; + + panicTimeout = mkOption { + type = types.nullOr types.ints.u16; + default = 30; + }; + }; + + # Based on nixpkgs/nixos/modules/installer/netboot/netboot.nix + config = mkIf cfg.enable { + boot = { + initrd = { + kernelModules = [cfg.networkDriver]; + + network = { + enable = true; + flushBeforeStage2 = true; + ssh = { + enable = true; + ignoreEmptyHostKeys = true; + }; + }; + + preFailCommands = mkIf (!cfg.setupMode) '' + echo "init stage1 failed in unattended mode, rebooting" + reboot -f + ''; + + postResumeCommands = '' + store_img_dir="/nix/.ro-store-img" + store_img="$store_img_dir/nix-store.squashfs" + + mkdir -p "$store_img_dir" + mount -t tmpfs -o mode=0700,nr_inodes=2,huge=within_size,nodev,nosuid tmpfs "$store_img_dir" + + ip link set up ${cfg.initrdInterface} + ip addr add ${cfg.linkLocal6}/64 dev ${cfg.initrdInterface} + + recv_init_timeout=120 + echo -n "Waiting up to ''${recv_init_timeout}s for $store_img" + + t="$recv_init_timeout" + while true; do + echo -n . | nc -u -w1 -s ${cfg.linkLocal6}%${cfg.initrdInterface} fe80::b007:b007%${cfg.initrdInterface} 1792 >/dev/null 2>&1 & + + if [ -e "$store_img.tmp" ]; then + kill -x nc + break + elif [ "$t" -le 0 ]; then + echo "timed out" + fail + fi + + # sleep builtin de ash retorna cuando nc termina (lo cual puede pasar muchas veces debido a IPv6 DAD) + /bin/sleep 1 + echo -n . + t=$((t - 1)) + done + + recv_poll=60 + recv_timeout=1800 + + t=0 + last_mtime="" + poll_time="$recv_poll" + while true; do + if [ -e "$store_img" ]; then + break + elif [ "$t" -ge "$recv_timeout" ]; then + echo "Store image upload timed out" + fail + fi + + sleep 1 + t=$((t + 1)) + poll_time=$((poll_time - 1)) + + if [ "$poll_time" -le 0 ]; then + current_size=$(stat -c '%s' "$store_img.tmp" || echo -) + current_mtime=$(stat -c '%y' "$store_img.tmp" || echo final) + echo "Store upload: $current_mtime: $current_size bytes" + + if [ "$current_mtime" = "$last_mtime" ]; then + echo "Store image upload stalled, image not modified in ''${recv_poll}s" + fail + fi + + last_mtime="$current_mtime" + poll_time="$recv_poll" + fi + done + + sleep 1 + kill -x sshd + + chmod 0500 "$store_img_dir" + chmod 0400 "$store_img" + chattr +i "$store_img" + mount -o remount,ro "$store_img_dir" + + store_size=$(stat -c '%s' "$store_img") + echo "Store image upload complete, final size is $store_size bytes" + + ln -sf "$store_img" /dev/nix-store + ''; + + postMountCommands = '' + mkdir -p "/mnt-root$store_img_dir" + mount --move "$store_img_dir" "/mnt-root$store_img_dir" + ''; + }; + + kernelParams = + optional (cfg.panicTimeout != null) "panic=${toString cfg.panicTimeout}" + ++ optional cfg.setupMode "boot.shell_on_fail"; + }; + + fileSystems = { + "/" = { + fsType = "tmpfs"; + neededForBoot = true; + + options = ["mode=0755"]; + }; + + "/nix/store" = { + fsType = "overlay"; + neededForBoot = true; + + overlay = { + lowerdir = ["/nix/.ro-store"]; + upperdir = "/nix/.rw-store/store"; + workdir = "/nix/.rw-store/work"; + }; + }; + + "/nix/.ro-store" = { + fsType = "squashfs"; + neededForBoot = true; + + device = "/dev/nix-store"; + options = ["loop" "threads=multi"]; + }; + + "/nix/.rw-store" = { + fsType = "tmpfs"; + neededForBoot = true; + + options = ["mode=0755"]; + }; + }; + + local = { + boot.loader = "out-of-band"; + }; + }; +} 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..da6f73a --- /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 = cfg.initrd.enable -> config.local.boot.efi.enable; + message = "TPM2 in initrd 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/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..10bdece --- /dev/null +++ b/sys/hardware/default.nix @@ -0,0 +1,12 @@ +{ + imports = [ + ./altera.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..1c77675 --- /dev/null +++ b/sys/hardware/yubico.nix @@ -0,0 +1,62 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.local.hardware.yubico; +in { + options = { + local.hardware.yubico = { + enable = mkEnableOption "Yubico hardware support"; + + pamAuth = mkOption { + type = lib.types.bool; + default = false; + }; + }; + + security.pam.services = mkOption { + type = with lib.types; + attrsOf (submodule { + config.u2fAuth = lib.mkDefault false; + }); + }; + }; + + config = mkIf cfg.enable { + environment.etc."pkcs11/modules/ykcs11".text = '' + module: ${pkgs.yubico-piv-tool}/lib/libykcs11.so + ''; + + security.pam = mkIf cfg.pamAuth { + u2f = { + enable = true; + control = "sufficient"; + + settings = { + authfile = "/var/trust/pam_u2f_keys"; + cue = true; + pinverification = 1; + userpresence = 0; + userverification = 0; + }; + }; + + services = { + gtklock.u2fAuth = true; + login.u2fAuth = true; + su.u2fAuth = true; + sudo.u2fAuth = true; + systemd-run0.u2fAuth = true; + vlock.u2fAuth = true; + }; + }; + + 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..d0d551f --- /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.runCommandLocal "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..64fd634 --- /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 = ["dovecot.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..2bd0cdd --- /dev/null +++ b/sys/mta/default.nix @@ -0,0 +1,270 @@ +{ + 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; + }; + + mtaListen = 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; + + # 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))); + }; + + settings.main = + { + mydomain = domain; + myhostname = mtaDomain.main; + inet_interfaces = [cfg.mtaListen]; + + myorigin = "$mydomain"; + #TODO: check_recipient_access para rechazar localhost desde afuera + mydestination = optionals isPrimary ["localhost" "$mydomain"]; + mynetworks_style = "host"; + + relayhost = optional isBackup "[${domains.smtp.main}]"; + relay_domains = + if isBackup + then allDomains + else null; + + smtpd_tls_chain_files = ["${cert}/key.pem" "${cert}/fullchain.pem"]; + + # user+extension@domain.tld + recipient_delimiter = optionalString isPrimary "+"; + + message_size_limit = 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 { + 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"]; + + # Evita race condition en bind de inet_interfaces + systemd.services.postfix-setup = { + after = ["network-online.target"]; + wants = ["network-online.target"]; + }; + }; +} 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..764973c --- /dev/null +++ b/sys/net/interfaces.nix @@ -0,0 +1,120 @@ +{ + 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 + wireguard-tools + ]; + + 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) { + ${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..626993d --- /dev/null +++ b/sys/nspawn/dmz.nix @@ -0,0 +1,236 @@ +{ + 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; + }; + + mtaAddr6 = 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 = { + mtaAddr6 = dmzNet.hosts.mta.v6.address; + 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}]"; + mtaListen = cfg.mtaAddr6; + 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 = { + dovecot.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..56b6866 --- /dev/null +++ b/sys/preset/user.nix @@ -0,0 +1,79 @@ +{ + 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; + pamAuth = mkDefault true; + }; + + bluetooth.enable = mkDefault true; + }; + + net.enable = true; + + seat = { + enable = true; + graphical = mkDefault true; + }; + + #trivionomiconMotd.enable = 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..81eff01 --- /dev/null +++ b/sys/seat/default.nix @@ -0,0 +1,146 @@ +{ + 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; + enable32Bit = true; + extraPackages = optionals (elem "i915" cfg.videoDrivers) [ + pkgs.intel-vaapi-driver + pkgs.intel-media-driver + pkgs.vpl-gpu-rt + ]; + }; + + programs = { + dconf.enable = true; + + gtklock = { + enable = true; + + config = {}; + modules = []; + }; + }; + + services = { + libinput.enable = true; + + 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..dfef481 --- /dev/null +++ b/sys/virt/libvirt.nix @@ -0,0 +1,62 @@ +{ + 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; + 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..85b6020 --- /dev/null +++ b/sys/web/sites/default.nix @@ -0,0 +1,8 @@ +{ + imports = [ + ./home.nix + ./host.nix + ./portal.nix + ./pxe.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..c4d948e --- /dev/null +++ b/sys/web/sites/portal.nix @@ -0,0 +1,42 @@ +{ + config, + lib, + pkgs, + ... +}: +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 = { + "/".alias = "${pkgs.local.sysret-static}/"; + "/fsociety".return = "301 https://meet.posixlycorrect.com/%C6%92%C6%A8%C5%8F%C4%8B%D3%80%C9%99%CF%AE%D0%A3"; + "/.well-known/openpgpkey/hu/".alias = "/var/lib/pgp-wkd/"; + }; + }; + }; + }; +} diff --git a/sys/web/sites/pxe.nix b/sys/web/sites/pxe.nix new file mode 100644 index 0000000..54f3e56 --- /dev/null +++ b/sys/web/sites/pxe.nix @@ -0,0 +1,180 @@ +{ + config, + flakes, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.local.web.sites.pxe; + + pxeEnabled = machine: machine.config.local.boot.pxe.enable; + machines = listToAttrs (map (name: { + inherit name; + value = flakes.self.nixosConfigurations.${name}; + }) + cfg.machines); + + stage2For = name: machine: + # https://purpleidea.com/blog/2025/01/23/working-around-an-ipxe-issue/ + # "The answer: + # They told me that one of their engineers had struggled with the same issue for a while. The solution: + # Add the imgfree iPXE command before you boot! + # I wish the docs would hint that this is often required, but now I know, and now you know too! + # You can see that line here." + pkgs.writeText "stage2-${name}.ipxe" '' + #!ipxe + echo stage2: booting ${name}... + imgfree + kernel kernel init=${machine.config.system.build.toplevel}/init ${concatStringsSep " " machine.config.boot.kernelParams} || goto fail + initrd initrd || goto fail + boot || + :fail + echo stage2: failed, rebooting in 5 seconds... + sleep 5 + reboot --warm + ''; + + machineLocations = name: machine: let + mac = machine.config.local.boot.pxe.mac; + build = machine.config.system.build; + in { + "= /${mac}/initrd".alias = "${build.initialRamdisk}/initrd"; + "= /${mac}/kernel".alias = "${build.kernel}/${pkgs.stdenv.hostPlatform.linux-kernel.target}"; + "= /${mac}/stage2.ipxe".alias = "${stage2For name machine}"; + }; +in { + options.local.web.sites.pxe = { + enable = mkEnableOption "PXE netboot image server"; + + linkLocal6 = mkOption { + type = types.str; + }; + + network = mkOption { + type = types.str; + }; + + machines = mkOption { + type = types.listOf types.str; + default = []; + }; + }; + + config = mkIf cfg.enable { + assertions = + mapAttrsToList (name: machine: { + message = "PXE boot is not enabled in config '${name}'"; + assertion = pxeEnabled machine; + }) + machines; + + local.web.enable = mkDefault true; + + networking = { + firewall.extraCommands = '' + ip6tables -t filter -A local-input -p udp -i ${cfg.network} --dport 1792 -d ${cfg.linkLocal6} -j ACCEPT + ''; + }; + + services = { + nginx = { + virtualHosts = { + "[${cfg.linkLocal6}]" = { + listenAddresses = ["[${cfg.linkLocal6}]"]; + + addSSL = false; + forceSSL = false; + + locations = mergeAttrsList (mapAttrsToList machineLocations (filterAttrs (_: pxeEnabled) machines)); + }; + }; + }; + }; + + systemd = { + network = { + networks.${cfg.network}.addresses = [ + { + Address = "${cfg.linkLocal6}/128"; + AddPrefixRoute = "no"; + PreferredLifetime = "0"; + } + ]; + }; + + services = { + pxe-store-upload = { + path = [config.nix.package pkgs.sshfs pkgs.squashfsTools]; + + script = let + host = lib.throw "TODO: pxe host"; + machine = machines.${host}; + in '' + set -e + + pxe_ip='${machine.config.local.boot.pxe.linkLocal6}' + pxe_host='${host}' + pxe_system='${machine.config.system.build.toplevel}' + + mountpoint="./pxe-initrd.$pxe_host" + store_img="$mountpoint/nix-store.squashfs" + + mkdir -p "$mountpoint" + + export PATH="/run/wrappers/bin:$PATH" + sshfs \ + "root@[$pxe_ip%${cfg.network}]:/nix/.ro-store-img/" \ + "$mountpoint" \ + -o idmap=user + + nix-store --query --requisites "$pxe_system" | \ + xargs -I{} mksquashfs {} - \ + -stream -comp zstd -one-file-system -no-xattrs \ + >"$store_img.tmp" + + mksquashfs -fix "$store_img.tmp" + + sync "$store_img.tmp" + mv -- "$store_img.tmp" "$store_img" + fusermount3 -u "$mountpoint" + ''; + + serviceConfig = { + #AmbientCapabilities = ["CAP_SYS_ADMIN"]; + #CapabilityBoundingSet = ["CAP_SYS_ADMIN"]; + #BindPaths = ["/dev/fuse"]; + #BindReadOnlyPaths = ["/nix/store"]; + #DeviceAllow = ["/dev/fuse rwm"]; + #DevicePolicy = "closed"; + #MountAPIVFS = true; + ProtectSystem = "strict"; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + #PrivateDevices = true; + #PrivateTmp = true; + #PrivateUsers = true; + #TemporaryFileSystem = ["/"]; + #DynamicUser = true; + RuntimeDirectory = "pxe-store-upload"; + WorkingDirectory = "/run/pxe-store-upload"; + }; + }; + }; + + sockets = { + pxe-store-upload = { + after = ["network-online.target"]; + wants = ["network-online.target"]; + wantedBy = ["sockets.target"]; + + socketConfig = { + ListenDatagram = "[${cfg.linkLocal6}]:1792%%${cfg.network}"; + }; + }; + }; + }; + }; +} |
