{ inputs, lib, namespace, }: let inherit (inputs.nixpkgs.lib) mapAttrs mkOption types toUpper substring stringLength mkDefault mkForce ; in rec { # Conditionally enable modules based on system enableForSystem = system: modules: builtins.filter ( mod: mod.systems or [ ] == [ ] || builtins.elem system (mod.systems or [ ]) ) modules; # Create a NixOS module with standard options (enable, port, reverseProxy, # firewall, user, postgresql, redis) and optional caller-supplied options and # config. All config is gated behind `cfg.enable`. mkModule = { name, description ? "", options ? { }, moduleConfig ? { }, domain ? "services", config, serviceName ? name, }: let cfg = config.${namespace}.${domain}.${name}; upstreamUrl = if cfg.reverseProxy.upstreamUrl != null then cfg.reverseProxy.upstreamUrl else "http://127.0.0.1:${toString cfg.port}"; fqdn = "${cfg.reverseProxy.subdomain}.${cfg.reverseProxy.domain}"; defaultConfig = { # Caddy reverse proxy: when reverseProxy.enable = true, contribute this # service's named-matcher block into the shared wildcard virtual host. services.caddy.virtualHosts."*.${cfg.reverseProxy.domain}" = lib.mkIf cfg.reverseProxy.enable { extraConfig = '' @${name} host ${fqdn} handle @${name} { reverse_proxy ${upstreamUrl} ${cfg.reverseProxy.extraCaddyConfig} } ''; }; # Open firewall networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; allowedUDPPorts = [ cfg.port ]; }; users = lib.mkIf cfg.createUser { users.${name} = { isSystemUser = true; group = name; home = cfg.configDir; }; groups.${name} = { }; }; # Ensure the service waits for the filesystem that hosts configDir and # dataDir to be mounted before starting. RequiresMountsFor is the # idiomatic systemd way to express this: if the paths live on the root # filesystem the directive is silently ignored, so it is safe on every # host — not just the NAS. systemd.services.${serviceName}.unitConfig.RequiresMountsFor = [ cfg.configDir cfg.dataDir ]; services = { postgresql = lib.mkIf cfg.configureDb { enable = true; ensureDatabases = [ name ]; ensureUsers = [ { name = name; ensureDBOwnership = true; } ]; }; redis.servers.${name} = lib.mkIf cfg.redis.enable { enable = true; port = cfg.redis.port; }; }; }; in { lib, ... }: { imports = [ # defaultConfig and moduleConfig are kept as separate inline modules so # the NixOS module system handles all merging (mkIf, mkForce, mkMerge, # etc.) correctly, rather than merging raw attrsets with // or # recursiveUpdate which can silently clobber mkIf wrappers. { config = lib.mkIf cfg.enable defaultConfig; } { config = lib.mkIf cfg.enable moduleConfig; } ]; options.${namespace}.${domain}.${name} = lib.mkOption { type = lib.types.submodule { options = { enable = lib.mkEnableOption description; port = mkOpt types.int 80 "Port for ${name} to listen on"; configDir = mkOpt types.str "/var/lib/${name}" "Path to the config directory"; dataDir = mkOpt types.str "/var/lib/${name}/data" "Path to the data directory"; createUser = mkBoolOpt false "Create a dedicated system user for this service"; configureDb = mkBoolOpt false "Manage a PostgreSQL database for this service"; environmentFile = mkOpt (types.nullOr types.str) null "Path to an environment file (EnvironmentFile=)"; puid = mkOpt types.str "911" "User ID for container-based services"; pgid = mkOpt types.str "100" "Group ID for container-based services"; timeZone = mkOpt types.str "UTC" "Timezone for container-based services"; listenAddress = mkOpt types.str "0.0.0.0" "Listen address"; openFirewall = mkBoolOpt true "Open firewall ports for this service"; redis = { enable = lib.mkEnableOption "a dedicated Redis server for this service"; port = mkOpt types.int 6379 "Redis port for ${name}"; }; hashedPassword = mkOpt (types.nullOr types.str) null "Hashed password (e.g. for web-based authentication)"; extraEnvironment = mkOpt (types.attrsOf types.str) { } "Extra environment variables passed to the service"; reverseProxy = mkReverseProxyOpt name; } // options; }; default = { }; }; }; # --------------------------------------------------------------------------- # Option creation helpers # --------------------------------------------------------------------------- mkOpt = type: default: description: mkOption { inherit type default description; }; mkOpt' = type: default: mkOpt type default ""; mkBoolOpt = mkOpt types.bool; mkBoolOpt' = mkOpt' types.bool; mkReverseProxyOpt = name: { enable = mkBoolOpt false "Enable Caddy reverse proxy for this service"; subdomain = mkOpt types.str name "Subdomain for the service (default: service name)"; domain = mkOpt types.str "mjallen.dev" "Base domain for the reverse proxy"; upstreamUrl = mkOpt (types.nullOr types.str) null "Override upstream URL (e.g. for a service on a different host). Defaults to http://127.0.0.1:."; extraCaddyConfig = mkOpt types.lines "" "Extra Caddyfile directives inside this virtual host block"; }; # --------------------------------------------------------------------------- # Convenience shorthands # --------------------------------------------------------------------------- enabled = { enable = true; }; disabled = { enable = false; }; # --------------------------------------------------------------------------- # String utilities # --------------------------------------------------------------------------- capitalize = s: let len = stringLength s; in if len == 0 then "" else (toUpper (substring 0 1 s)) + (substring 1 len s); # --------------------------------------------------------------------------- # Boolean utilities # --------------------------------------------------------------------------- boolToNum = bool: if bool then 1 else 0; # --------------------------------------------------------------------------- # Attribute manipulation utilities # --------------------------------------------------------------------------- default-attrs = mapAttrs (_key: mkDefault); force-attrs = mapAttrs (_key: mkForce); nested-default-attrs = mapAttrs (_key: default-attrs); nested-force-attrs = mapAttrs (_key: force-attrs); }