commit fb98563bb60db46ef2b538d0cc8ecb6ed128fbf0 Author: Katharina Heidenreich Date: Sat Apr 4 22:19:24 2026 +0200 feat: add initial config diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..07669ee --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,6 @@ +creation_rules: + - path_regex: ^secrets/.*(?:$|\.(ya?ml|json|env|txt|key|pub))$ + key_groups: + - age: + - age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj + - age13p5fukn4eetfqr5jvuc7xv6gwpn8hthq59ay5k2chm0c0409r9jq97dpyz \ No newline at end of file diff --git a/config/endpoints.nix b/config/endpoints.nix new file mode 100644 index 0000000..db34a8f --- /dev/null +++ b/config/endpoints.nix @@ -0,0 +1,4 @@ +let + piTunnelEndpoints = import ../config/endpoints/pi_tunnel.nix; +in +piTunnelEndpoints ++ [] \ No newline at end of file diff --git a/config/endpoints/pi_tunnel.nix b/config/endpoints/pi_tunnel.nix new file mode 100644 index 0000000..9f8a690 --- /dev/null +++ b/config/endpoints/pi_tunnel.nix @@ -0,0 +1,13 @@ +let + ports = [80 443 8448]; + entry = port: + { + type = "forwarding"; + listenPort = port; + content = { + port = 10000 + port; + }; + }; + +in + map entry ports diff --git a/config/network.nix b/config/network.nix new file mode 100644 index 0000000..045a02f --- /dev/null +++ b/config/network.nix @@ -0,0 +1,10 @@ +{ + tunnel = { + host = "127.0.0.1"; + allowedPorts = [ + 10080 + 10443 + 18448 + ]; + }; +} diff --git a/config/openssh.nix b/config/openssh.nix new file mode 100644 index 0000000..a40db75 --- /dev/null +++ b/config/openssh.nix @@ -0,0 +1,22 @@ +let + secrets = import ../intermediate/secrets.nix; + users = builtins.attrNames secrets.source.openssh.users; +in +rec { + ssh_users = users; + + extraConfig = { + users = { + "autossh-incoming" = '' + PasswordAuthentication no + PermitTTY no + X11Forwarding no + AllowAgentForwarding no + PermitTunnel no + AllowTcpForwarding remote + PermitListen localhost:* + PermitListen 127.0.0.1:* + ''; + }; + }; +} \ No newline at end of file diff --git a/config/secrets.nix b/config/secrets.nix new file mode 100644 index 0000000..ffc0738 --- /dev/null +++ b/config/secrets.nix @@ -0,0 +1,25 @@ +{ + openssh = { + users = { + "autossh-incoming" = { + pub_keys = { + file = ../secrets/openssh/autossh_incoming/pub_keys; + path = "/var/lib/autossh-incoming/.ssh/authorized_keys"; + owner = "autossh-incoming"; + group = "autossh-incoming"; + mode = "0600"; + }; + }; + + nudelerde = { + pub_keys = { + file = ../secrets/openssh/nudelerde/pub_keys; + path = "/home/nudelerde/.ssh/authorized_keys"; + owner = "nudelerde"; + group = "users"; + mode = "0600"; + }; + }; + }; + }; +} \ No newline at end of file diff --git a/config/services.nix b/config/services.nix new file mode 100644 index 0000000..d1c9cf3 --- /dev/null +++ b/config/services.nix @@ -0,0 +1,6 @@ +{ + nginx = { + enable = true; + acmeEmail = null; + }; +} \ No newline at end of file diff --git a/configuration.nix b/configuration.nix new file mode 100644 index 0000000..30e4847 --- /dev/null +++ b/configuration.nix @@ -0,0 +1,18 @@ +{ config, pkgs, ... }: +let + sopsNixVersion = "8f093d0d2f08f37317778bd94db5951d6cce6c46"; +in +{ + imports = [ + "${builtins.fetchTarball "https://github.com/Mic92/sops-nix/archive/${sopsNixVersion}.tar.gz"}/modules/sops" + ./hardware-configuration.nix + ./system + ./services + ./programs + ]; + + environment.systemPackages = with pkgs; [ wget tree ]; + + system.stateVersion = "25.11"; +} + diff --git a/hardware-configuration.nix b/hardware-configuration.nix new file mode 100644 index 0000000..52293e4 --- /dev/null +++ b/hardware-configuration.nix @@ -0,0 +1,30 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ config, lib, pkgs, modulesPath, ... }: + +{ + imports = + [ (modulesPath + "/profiles/qemu-guest.nix") + ]; + + boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "sr_mod" "virtio_blk" ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ ]; + boot.extraModulePackages = [ ]; + + fileSystems."/" = + { device = "/dev/disk/by-uuid/ec5f3d98-74b1-4c33-aad2-c9d26b38958d"; + fsType = "ext4"; + }; + + fileSystems."/boot" = + { device = "/dev/disk/by-uuid/5936-3F61"; + fsType = "vfat"; + options = [ "fmask=0022" "dmask=0022" ]; + }; + + swapDevices = [ ]; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; +} diff --git a/intermediate/nginx.nix b/intermediate/nginx.nix new file mode 100644 index 0000000..18c561d --- /dev/null +++ b/intermediate/nginx.nix @@ -0,0 +1,36 @@ +let + lib = import ; + endpoints = (import ../validation/endpoints.nix).getValidatedEndpoints (import ../config/endpoints.nix); + net = import ../config/network.nix; + tunnelPolicy = import ../validation/tunnel_ports.nix; + + normalizeEndpoint = endpoint: + endpoint // { + content = endpoint.content // { + host = if endpoint.type == "forwarding" then net.tunnel.host else endpoint.content.host; + }; + }; + + normalizedEndpoints = map normalizeEndpoint endpoints; + + _forwardPortChecks = map (endpoint: + if endpoint.content.host == net.tunnel.host && !(tunnelPolicy.isAllowedTunnelPort endpoint.content.port) then + throw "Forwarding endpoint listenPort=${toString endpoint.listenPort} targets tunnel-backed local port ${toString endpoint.content.port}, which is not listed in config/network.nix tunnel.allowedPorts." + else + null + ) normalizedEndpoints; + + mkStreamServer = endpoint: '' + server { + listen ${toString endpoint.listenPort}; + proxy_pass ${endpoint.content.host}:${toString endpoint.content.port}; + } + ''; + + streamConfig = lib.concatStringsSep "\n" (map mkStreamServer normalizedEndpoints); +in +{ + validatedEndpoints = normalizedEndpoints; + inherit streamConfig; + nginxUsedPorts = lib.unique (map (endpoint: endpoint.listenPort) normalizedEndpoints); +} \ No newline at end of file diff --git a/intermediate/secrets.nix b/intermediate/secrets.nix new file mode 100644 index 0000000..ea3a842 --- /dev/null +++ b/intermediate/secrets.nix @@ -0,0 +1,79 @@ +let + lib = import ; + secretsConfig = (import ../validation/secrets.nix).getSecretsConfig (import ../config/secrets.nix); + + getRuntimePath = path: + "/run/secrets/${builtins.concatStringsSep "_" path}"; + + defaultMetadata = { + path = null; + owner = null; + group = null; + mode = null; + }; + + normalizeLeaf = path: node: + if builtins.isString node || builtins.isPath node then + { + file = node; + metadata = defaultMetadata; + } + else if builtins.isAttrs node && node ? file then + { + file = node.file; + metadata = { + path = node.path or null; + owner = node.owner or null; + group = node.group or null; + mode = node.mode or null; + }; + } + else + throw "Invalid secret leaf at ${builtins.concatStringsSep "." path}: must be string, path, or attrset with 'file'"; + + flattenTree = path: node: + if builtins.isAttrs node && !(node ? file) then + lib.concatMap (name: + flattenTree (path ++ [ name ]) node.${name} + ) (builtins.attrNames node) + else + let + normalized = normalizeLeaf path node; + in + [ { + inherit path; + file = normalized.file; + metadata = normalized.metadata; + } ]; + + entries = flattenTree [] secretsConfig; + + isReady = entry: + builtins.pathExists entry.file; + + readyEntries = builtins.filter isReady entries; + missingEntries = builtins.filter (entry: !(isReady entry)) entries; + + mkSopsSecrets = sourceEntries: + builtins.listToAttrs (map (entry: + let + secretName = builtins.concatStringsSep "_" entry.path; + in + { + name = secretName; + value = { + sopsFile = entry.file; + format = "binary"; + path = if entry.metadata.path != null then entry.metadata.path else getRuntimePath entry.path; + owner = if entry.metadata.owner != null then entry.metadata.owner else "root"; + group = if entry.metadata.group != null then entry.metadata.group else "root"; + mode = if entry.metadata.mode != null then entry.metadata.mode else "0400"; + }; + } + ) sourceEntries); +in +{ + source = secretsConfig; + byName = mkSopsSecrets readyEntries; + missing = missingEntries; +} \ No newline at end of file diff --git a/programs/default.nix b/programs/default.nix new file mode 100644 index 0000000..aeeaa67 --- /dev/null +++ b/programs/default.nix @@ -0,0 +1,3 @@ +{ ... }: +{ +} \ No newline at end of file diff --git a/secrets/openssh/autossh_incoming/pub_keys b/secrets/openssh/autossh_incoming/pub_keys new file mode 100644 index 0000000..22188e2 --- /dev/null +++ b/secrets/openssh/autossh_incoming/pub_keys @@ -0,0 +1,19 @@ +{ + "data": "ENC[AES256_GCM,data:Z1kf7mA5QD004A/NLI/71YL8yFedXBwNFjke5jAv+43BzLyx+xd0swx1Y7MvHPrqQ82Dil04VWyP03oSHGWkMYGqXcY0rRMJZlB5Ky249Tz7muYa0eeukEfWZrZrgiaH,iv:oUFWYvlXc2QBSFe1tG6ylZmo+ft6SZE84bf2goY/pQU=,tag:a+gAB3X5/CK3nlE9pU5ctA==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnSnYvVUdzN1BWYlYyZWdm\nY0ZBcm5pczJObG5SWjlRVG5jY3EvVkthNzM0CkdYYk9mdVlkcDd5bHJHQUlBbllj\ncDNjMlBsYUc0KzVZaTcwemE4Tk9heDAKLS0tIHB1VDVtTUdZTWVxSGZ0YVN3bnJu\ncDBpTlVPNHlla3Z4UjEyenZFTWxMbEkKoo00iOt7ijUIfa1JH2kV/XP8pRrHl3S4\nCfNDMwckHPMFJcfPv5W7HIFE/qICUdQddIjeKwKCEwzuBLYxIDZs9w==\n-----END AGE ENCRYPTED FILE-----\n" + }, + { + "recipient": "age13p5fukn4eetfqr5jvuc7xv6gwpn8hthq59ay5k2chm0c0409r9jq97dpyz", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtV2F3ekFrVWJZa1oxcW5t\nRjZlYnZRV1R6QmR3b25MbG10b0cyckpGaXowCjgwTTJpenJGYzJJSis5UmE2ZWVK\ndW9RMEhLUmczZ3dPSktERlRtRHF6TlkKLS0tIGZvQXoyNzlKeW5sNEF4dkQrOXl2\nelRYV253cngwdllVVWUzMGRiUSs5cTQKmo7YEYpPeOt3NyzxvvkwndUcLf2Mxr5F\nqd4ET6CIvh0J8N74QpIA4QPpi0dXVKlGNZ4Yr+HouKgr31gKse8Urw==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-04T19:52:29Z", + "mac": "ENC[AES256_GCM,data:jGgaLSj5mv7aR/h1TtfZlBg26Mi5w6JJiI5gj4Ij+ODYAeCq9r/tz4Kl9nPp8js0mgUFdXkJ1HHkkkJ+Ty+O0qEQCSh3hHeXcWSE3sOQVcqXLLfmFiJXHuX5cKpQrjZSJdaEK38wOUY67mrP1JpB1RDsp3AFUSgMx6do8TBtLHQ=,iv:qX+uscNvOkGI+05ew5thMGVe/58Mf8Cpli+dugtM2KM=,tag:zfUwrTjzlvo8kz21fM9Zxw==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secrets/openssh/nudelerde/pub_keys b/secrets/openssh/nudelerde/pub_keys new file mode 100644 index 0000000..dd2800d --- /dev/null +++ b/secrets/openssh/nudelerde/pub_keys @@ -0,0 +1,19 @@ +{ + "data": "ENC[AES256_GCM,data:a2lXkJJzcxy1uNn5eWeytRzw8LjAXhRyMB4siqBhogqv6m5omFU8erKtPjx26yejVFnmgp/612Q65D0gVi5A+qEButRTPiPEdgh5svzcj38cUNaqC4NQqFc6mv2LJjJfKGulUS1PgIMeLk2rUMsP0zLQHweFzDjOblew1vQUdTfcvSETpaG/fKa/8FTfx88eOCvAYJuhI6jJDOtOuAjlq+OXVW+cGBV9vLCrQfty0rVnNp274TNcctvJmjPde0UhwfmbX4fumgUc62vnUVrkdKgcwVQ1QuhLvSB2ySa4OdYBcxNk19xrDLG0KHWkyk6KWf6enogO0+E8pLRmU/onoygxohA76GBdy9cStJZFl8YT3sTk+eQDicdXo1pjVaBE5PtC/qzIiq8y04egtf/1+q1NishztmPqAp8+jGNdqyjLPZlYKkPKD/LLc8LNMT6Ko69zg0vMl5E16pwAJJE+qgMTSrfdoQ/XxOf2KDm14fTRVvnhdJfT0l9WOtumkleyd3S1S/q6ZVRN/IvPa7TUhplradfUdDYgKL9DW1JtdtBLBuer2Lbw6jnAAJD4JyNBEmgXDCPIewl5Ubre7vwr4FWvKQApN8jmkHpJAvjDOHBpMMAvXTUbz/cZhmiCSHOWmYkMWKgpaW78MiYOoYrrwpeEJaKuH2ZeKBup5GifAz8u1AozrBE7bzO97JeH4glLacHbQ0BLWOinjxIpFAZH+0g8xP/cFpu8M6lCoaN5N50BwFDQcAUGR5NfbsBBg02tAHuDYs9l9fdhBPBZXtHdyo9hRtRybY3o3uzTHWVr8Hii9FfOrYyKANNv/QCa1u2xgbsw2c3yJJqDoetP/dtimzqEW4K4fLCBDJsz/hmkgpx6Pvv8WQ3MCsjUfuf9RRnbGR6zkoibJMxpU6RQoXQvgbW38nskF6/qXXgnDQURypRuX4L4JTHP8nZqmgldS28MhpyMHfAnl8lXzL2Spf5vCnjrI1qsAiz4LJ+iCFn/ne4dwlQiztdypQ==,iv:JCePPGSh9GO3hctfjGSzJ8o6nQXT5WA38XzFIGxMx14=,tag:4WtGrtNQIuZOR0OBQ7FWlQ==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3Ky9vMk5OTkVHc0d2UXlL\nWGY0Q3REcVF5bHZCQTZqMkxyc3llUmErL2hNClcweVg3dDNFaUtrRjdzRHN3YzRq\nQ1lvNFR3U3lSU015MFlDNE8zUUxpaVUKLS0tIGZNUnRKbzczSWo1czhyOFBKUXFN\nTTJyWHhDOSt3RzRjbkRGKzlCdndIYmsKe7CUIoSFtFFCFF0DdCF7C3BUP8gAyvM/\naJWvqSBFC9BUyMN39q1qltZagVZjdo3xoQQ9YbCmgnHhKOLOyPYsag==\n-----END AGE ENCRYPTED FILE-----\n" + }, + { + "recipient": "age13p5fukn4eetfqr5jvuc7xv6gwpn8hthq59ay5k2chm0c0409r9jq97dpyz", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4aWZYa3hPUHBoU2JIdmY1\nQVhrYkJUaTFoL2EycDN4V3VOQlhtTUFzcEFNCjN4QnNKVTNSVjJUTWpPVEVCU0d3\ncDZ3ZkNvd0R4d2lLNzVHZkhzOUxLTlUKLS0tIFRHVGtjWWFtblBOR0o2czJ6SFdr\nKzloQmcxQUx1QURZTVZHaVlyK1VrZkEKWzgu4cEb5kS7MNs09rAFvMmcp8oE0lFO\nMf2XYWELnakh2p68MWNjrKlsg/P1iPtMKs8EQLLBz3+DDl0dy6Bdig==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-04T19:32:16Z", + "mac": "ENC[AES256_GCM,data:uNrX4EQWcyYS/QV0cConm9x45rNeSC0RzKhiKQ8235+TLHzwBE2prD2tVPBQtHQ2HzhVNd8Uw1LwF6R1ZgDJimcDL3oeLMrbIHvl8RcQ5c5jgYsfUI7X4P182QUXxEQ14o/xhCoK7rtjPqY+069DMp3VEzdsbDuRDf1HkGy3Ljo=,iv:o3A65kabdjViYUrzXoVppybg5ROnbhU/1QQBHO1Xl80=,tag:6nCI8KpqaaWeqCyA7NPHXg==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/services/default.nix b/services/default.nix new file mode 100644 index 0000000..e38eec5 --- /dev/null +++ b/services/default.nix @@ -0,0 +1,7 @@ +{ ... }: +{ + imports = [ + ./openssh.nix + ./nginx.nix + ]; +} \ No newline at end of file diff --git a/services/nginx.nix b/services/nginx.nix new file mode 100644 index 0000000..4e17fbe --- /dev/null +++ b/services/nginx.nix @@ -0,0 +1,25 @@ +{ config, lib, ... }: +let + serviceConfig = import ../config/services.nix; + nginxModel = import ../intermediate/nginx.nix; +in +{ + assertions = [ + { + assertion = nginxModel.validatedEndpoints != []; + message = "No endpoints configured. Add endpoint declarations under config/endpoints/."; + } + ]; + + services.nginx = { + enable = serviceConfig.nginx.enable; + + recommendedProxySettings = true; + recommendedTlsSettings = true; + recommendedOptimisation = true; + recommendedGzipSettings = true; + streamConfig = nginxModel.streamConfig; + }; + + networking.firewall.allowedTCPPorts = nginxModel.nginxUsedPorts; +} \ No newline at end of file diff --git a/services/openssh.nix b/services/openssh.nix new file mode 100644 index 0000000..65c4f36 --- /dev/null +++ b/services/openssh.nix @@ -0,0 +1,32 @@ +{ ... }: +let + lib = import ; + opensshConfig = import ../config/openssh.nix; + + userExtraConfig = + if opensshConfig ? extraConfig && opensshConfig.extraConfig ? users && builtins.isAttrs opensshConfig.extraConfig.users then + opensshConfig.extraConfig.users + else + {}; + + renderedUserMatches = lib.concatStringsSep "\n" ( + lib.mapAttrsToList (user: cfg: '' + Match User ${user} +${cfg} + '') userExtraConfig + ); +in +{ + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + GatewayPorts = "no"; + AllowUsers = opensshConfig.ssh_users; + }; + extraConfig = renderedUserMatches; + }; + + networking.firewall.allowedTCPPorts = [ 22 ]; +} \ No newline at end of file diff --git a/system/boot.nix b/system/boot.nix new file mode 100644 index 0000000..17cafef --- /dev/null +++ b/system/boot.nix @@ -0,0 +1,5 @@ +{ ... }: +{ + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; +} \ No newline at end of file diff --git a/system/default.nix b/system/default.nix new file mode 100644 index 0000000..8aca7a8 --- /dev/null +++ b/system/default.nix @@ -0,0 +1,9 @@ +{ ... }: +{ + imports = [ + ./sops.nix + ./users.nix + ./network.nix + ./boot.nix + ]; +} \ No newline at end of file diff --git a/system/network.nix b/system/network.nix new file mode 100644 index 0000000..1c926db --- /dev/null +++ b/system/network.nix @@ -0,0 +1,15 @@ +{ config, pkgs, lib, ... }: + +{ + networking.hostName = "nixos-proxy"; + networking.useDHCP = false; + networking.interfaces.eth0.ipv4.addresses = [ + { address = "193.31.24.99"; prefixLength = 22; } + ]; + networking.defaultGateway = "193.31.24.1"; + networking.interfaces.eth0.ipv6.addresses = [ + { address = "2a03:4000:2b:1836:748d:84ff:fe53:2a09"; prefixLength = 64; } + ]; + networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; }; + networking.nameservers = [ "1.1.1.1" "9.9.9.9" "2606:4700:4700::1111" "2620:fe::fe" ]; +} \ No newline at end of file diff --git a/system/sops.nix b/system/sops.nix new file mode 100644 index 0000000..9509d5d --- /dev/null +++ b/system/sops.nix @@ -0,0 +1,14 @@ +{ lib, ... }: + +let + secretData = import ../intermediate/secrets.nix; +in +{ + sops = { + age.keyFile = "/var/lib/sops-nix/key.txt"; + secrets = secretData.byName; + }; + + warnings = lib.optional (secretData.missing != []) + "Some SOPS source files are missing or not yet encrypted; no runtime secrets will be provisioned for: ${builtins.concatStringsSep ", " (map (item: builtins.concatStringsSep "_" item.path) secretData.missing)}"; +} \ No newline at end of file diff --git a/system/users.nix b/system/users.nix new file mode 100644 index 0000000..17e2878 --- /dev/null +++ b/system/users.nix @@ -0,0 +1,32 @@ +{ config, pkgs, lib, ... }: + +{ + users.mutableUsers = false; + + users.users.nudelerde = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + hashedPassword = "$y$j9T$NiaiVxQKs0C1V4VdCFKBO.$P6RfBDTyJfPJJzKyHf9PJEy9Ku5M6AU57U98nVD6wP6"; + }; + + users.users.autossh-incoming = { + isSystemUser = true; + group = "autossh-incoming"; + createHome = true; + home = "/var/lib/autossh-incoming"; + }; + + users.groups.autossh-incoming = {}; + + security.sudo.extraRules = [ + { + users = [ "nudelerde" ]; + commands = [ + { + command = "ALL"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; +} \ No newline at end of file diff --git a/validation/endpoints.nix b/validation/endpoints.nix new file mode 100644 index 0000000..3222dc1 --- /dev/null +++ b/validation/endpoints.nix @@ -0,0 +1,78 @@ +let + lib = import ; + + allowedEndpointKeys = [ "type" "listenPort" "content" ]; + allowedContentKeys = [ "host" "port" ]; + + ensureNoUnknownKeys = context: obj: allowedKeys: + let + unknown = builtins.filter (key: !(lib.elem key allowedKeys)) (builtins.attrNames obj); + in + if unknown != [] then + throw "${context} contains unknown keys: ${builtins.concatStringsSep ", " unknown}" + else + obj; + + ensurePort = value: + builtins.isInt value && value > 0 && value <= 65535; + + validateEndpointShape = index: endpoint: + let + _ = if builtins.isAttrs endpoint then null else throw "Endpoint at index ${toString index} must be an attrset."; + __ = ensureNoUnknownKeys "Endpoint at index ${toString index}" endpoint allowedEndpointKeys; + typeValue = + if endpoint ? type && builtins.isString endpoint.type then + endpoint.type + else + throw "Endpoint at index ${toString index} must define type as a string."; + _type = + if lib.elem typeValue [ "forwarding" "proxy" ] then + null + else + throw "Endpoint at index ${toString index} type must be 'forwarding' or 'proxy'."; + _listenPort = + if endpoint ? listenPort && ensurePort endpoint.listenPort then + null + else + throw "Endpoint at index ${toString index} must define listenPort as int in range 1..65535."; + contentValue = + if endpoint ? content then + endpoint.content + else + throw "Endpoint at index ${toString index} must define content."; + _content = + let + _attrs = if builtins.isAttrs contentValue then null else throw "Endpoint at index ${toString index} must define content as an attrset."; + ____ = ensureNoUnknownKeys "Endpoint content at index ${toString index}" contentValue allowedContentKeys; + in + if !(contentValue ? port) || !ensurePort contentValue.port then + throw "Endpoint at index ${toString index} must define content.port as int in range 1..65535." + else if typeValue == "proxy" && (!(contentValue ? host) || !builtins.isString contentValue.host || contentValue.host == "") then + throw "Proxy endpoint at index ${toString index} must define content.host as non-empty string." + else if typeValue == "forwarding" && contentValue ? host then + throw "Forwarding endpoint at index ${toString index} must not define content.host." + else + null; + in + endpoint; + + validateEndpointsShape = endpoints: + if !builtins.isList endpoints then + throw "config/endpoints.nix must evaluate to a list of endpoint attrsets." + else + lib.imap0 validateEndpointShape endpoints; + + validateUniqueHostPath = endpoints: + let + keyFor = endpoint: toString endpoint.listenPort; + keys = map keyFor endpoints; + in + if builtins.length keys == builtins.length (lib.unique keys) then + endpoints + else + throw "Duplicate listenPort detected in config/endpoints.nix."; +in +{ + getValidatedEndpoints = endpoints: + validateUniqueHostPath (validateEndpointsShape endpoints); +} \ No newline at end of file diff --git a/validation/secrets.nix b/validation/secrets.nix new file mode 100644 index 0000000..b3276ef --- /dev/null +++ b/validation/secrets.nix @@ -0,0 +1,29 @@ +let + lib = import ; + helperNames = [ "getSecretsConfig" ]; + + validateTree = context: node: + if builtins.isAttrs node then + lib.mapAttrs (name: value: + if lib.elem name helperNames then + value + else + validateTree "${context}.${name}" value + ) node + else + throw "${context} must be an attrset at non-leaf level."; + + getSecretsConfig = secretsConfig: + let + _ = + if builtins.isAttrs secretsConfig then + null + else + throw "config/secrets.nix must evaluate to an attrset."; + __ = validateTree "config/secrets.nix" secretsConfig; + in + secretsConfig; +in +rec { + inherit getSecretsConfig validateTree; +} \ No newline at end of file diff --git a/validation/tunnel_ports.nix b/validation/tunnel_ports.nix new file mode 100644 index 0000000..faeafee --- /dev/null +++ b/validation/tunnel_ports.nix @@ -0,0 +1,10 @@ +let + net = import ../config/network.nix; + allowedPorts = net.tunnel.allowedPorts; +in +{ + inherit allowedPorts; + + isAllowedTunnelPort = port: + builtins.isInt port && builtins.elem port allowedPorts; +} \ No newline at end of file diff --git a/validation/web.nix b/validation/web.nix new file mode 100644 index 0000000..dd09bda --- /dev/null +++ b/validation/web.nix @@ -0,0 +1,30 @@ +let + lib = import ; + + validateFile = siteName: index: file: + let + _ = if builtins.isAttrs file then null else throw "web.${siteName}.files[${toString index}] must be an attrset."; + __ = if file ? path && builtins.isString file.path then null else throw "web.${siteName}.files[${toString index}].path must be a string."; + ___ = if file ? content && builtins.isString file.content then null else throw "web.${siteName}.files[${toString index}].content must be a string."; + ____ = if file ? contentType && builtins.isString file.contentType && file.contentType != "" then null else throw "web.${siteName}.files[${toString index}].contentType must be a non-empty string."; + _____ = if file ? status && builtins.isInt file.status then null else throw "web.${siteName}.files[${toString index}].status must be an int."; + in + file; + + validateSite = siteName: site: + let + _ = if builtins.isAttrs site then null else throw "web.${siteName} must be an attrset."; + __ = if site ? files && builtins.isList site.files then null else throw "web.${siteName}.files must be a list."; + ___ = lib.imap0 (index: file: validateFile siteName index file) site.files; + in + site; + + getWebConfig = webConfig: + if builtins.isAttrs webConfig then + lib.mapAttrs validateSite webConfig + else + throw "config/web.nix must evaluate to an attrset."; +in +{ + inherit getWebConfig; +} \ No newline at end of file