{ inputs, lib, namespace, }: let inherit (inputs.nixpkgs.lib) mapAttrs mkOption types toUpper substring stringLength mkDefault mkForce ; in rec { # --------------------------------------------------------------------------- # NixOS service module helpers # --------------------------------------------------------------------------- # 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} } ''; }; 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} = { }; }; # RequiresMountsFor is silently ignored when the paths live on the root # filesystem, so this is safe on non-NAS hosts too. 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 { inherit (cfg.redis) enable port; }; }; }; in { lib, ... }: { imports = [ { 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; hostedService = { enable = mkOpt types.bool cfg.reverseProxy.enable "Expose this service in Glance dashboard (auto-enabled when reverseProxy is on)"; title = mkOpt types.str name "Display title in Glance"; icon = mkOpt types.str "si:glance" "Icon identifier for Glance (e.g. si:actualbudget)"; group = mkOpt types.str "Services" "Glance group/category for this service"; url = mkOpt types.str ( if cfg.reverseProxy.enable then "https://${cfg.reverseProxy.subdomain}.${cfg.reverseProxy.domain}" else "http://127.0.0.1:${toString cfg.port}" ) "Service URL for Glance (auto-derived from reverseProxy if enabled)"; basicAuth = mkOpt types.bool false "Require basic auth for this service in Glance"; }; } // options; }; default = { }; }; }; # Wraps mkModule for Podman/OCI container services. Generates all the # standard mkModule options plus the container definition. The serviceName # is set to "podman-" automatically. # # Required args: # config — the NixOS config attrset (pass through from the module args) # name — service name (used for the container name and option path) # image — OCI image reference string # internalPort — port the container listens on internally # # Optional args: # description — human-readable description (defaults to name) # options — extra mkModule options attrset # volumes — extra volume strings (in addition to none) # environment — extra environment variables (merged with PUID/PGID/TZ) # environmentFiles — list of paths to env-files (e.g. sops template paths) # extraOptions — list of extra --opt strings passed to the container runtime # devices — list of device mappings # extraConfig — extra NixOS config merged into moduleConfig mkContainerService = { config, name, image, internalPort, description ? name, options ? { }, volumes ? [ ], environment ? { }, environmentFiles ? [ ], extraOptions ? [ ], devices ? [ ], extraConfig ? { }, }: let cfg = config.${namespace}.services.${name}; in mkModule { inherit config name description options ; serviceName = "podman-${name}"; moduleConfig = lib.recursiveUpdate { virtualisation.oci-containers.containers.${name} = { autoStart = true; inherit image volumes environmentFiles extraOptions devices ; ports = [ "${toString cfg.port}:${toString internalPort}" ]; environment = { PUID = cfg.puid; PGID = cfg.pgid; TZ = cfg.timeZone; } // environment; }; } extraConfig; }; # Generates a sops secrets block + a sops template env-file in a single call. # # secrets — attrset of sops secret keys → extra attrs (e.g. owner/group). # The sopsFile is set automatically to nas-secrets.yaml unless # overridden per-secret via { sopsFile = ...; }. # name — template file name, e.g. "glance.env" # content — the template body string (use config.sops.placeholder."key") # restartUnit — systemd unit to restart when the secret changes # owner, group, mode — file ownership/permissions (defaults match NAS convention) # sopsFile — default sops file for all secrets (can be overridden per-secret) mkSopsEnvFile = { secrets, name, content, restartUnit, owner ? "nix-apps", group ? "jallen-nas", mode ? "660", sopsFile ? lib.snowfall.fs.get-file "secrets/nas-secrets.yaml", }: { sops.secrets = mapAttrs (_key: extra: { inherit sopsFile; } // extra) secrets; sops.templates.${name} = { inherit mode owner group content ; restartUnits = [ restartUnit ]; }; }; # --------------------------------------------------------------------------- # Home Manager module helper # --------------------------------------------------------------------------- # Create a Home Manager module with a standard enable option and optional # extra options, gating all config behind `cfg.enable`. # # domain — option namespace domain, e.g. "programs" or "desktop" # name — module name, e.g. "btop" # description — text for mkEnableOption (defaults to name) # options — attrset of extra options merged into the submodule # config — the NixOS/HM config attrset passed through from module args # moduleConfig — the Home Manager config body (already gated behind cfg.enable) mkHomeModule = { config, domain, name, description ? name, options ? { }, moduleConfig, }: let cfg = config.${namespace}.${domain}.${name}; in { lib, ... }: { options.${namespace}.${domain}.${name} = lib.mkOption { type = lib.types.submodule { options = { enable = lib.mkEnableOption description; } // options; }; default = { }; }; config = lib.mkIf cfg.enable moduleConfig; }; # --------------------------------------------------------------------------- # Option creation helpers # --------------------------------------------------------------------------- # 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); }