diff --git a/flake.nix b/flake.nix index b99a07f..b478954 100644 --- a/flake.nix +++ b/flake.nix @@ -61,6 +61,10 @@ }); nixosModules = { + nixpkgs-search-mcp = { pkgs, ... }: { + imports = [ ./nix/nixpkgs-search-module.nix ]; + services.nixpkgs-search.package = lib.mkDefault self.packages.${pkgs.system}.nixpkgs-search; + }; nixos-options-mcp = { pkgs, ... }: { imports = [ ./nix/module.nix ]; services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options; @@ -69,7 +73,7 @@ imports = [ ./nix/hm-options-module.nix ]; services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options; }; - default = self.nixosModules.nixos-options-mcp; + default = self.nixosModules.nixpkgs-search-mcp; }; }; } diff --git a/nix/nixpkgs-search-module.nix b/nix/nixpkgs-search-module.nix new file mode 100644 index 0000000..eaa564f --- /dev/null +++ b/nix/nixpkgs-search-module.nix @@ -0,0 +1,383 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.nixpkgs-search; + + # Determine database URL based on configuration + # For postgres with connectionStringFile, the URL is set at runtime via script + useConnectionStringFile = cfg.database.type == "postgres" && cfg.database.connectionStringFile != null; + + databaseUrl = if cfg.database.type == "sqlite" + then "sqlite://${cfg.dataDir}/${cfg.database.name}" + else if useConnectionStringFile + then "" # Will be set at runtime from file + else cfg.database.connectionString; + + # Build HTTP transport flags for a service + mkHttpFlags = httpCfg: lib.concatStringsSep " " ([ + "--transport http" + "--http-address '${httpCfg.address}'" + "--http-endpoint '${httpCfg.endpoint}'" + "--session-ttl '${httpCfg.sessionTTL}'" + ] ++ lib.optionals (httpCfg.allowedOrigins != []) ( + map (origin: "--allowed-origins '${origin}'") httpCfg.allowedOrigins + ) ++ lib.optionals httpCfg.tls.enable [ + "--tls-cert '${httpCfg.tls.certFile}'" + "--tls-key '${httpCfg.tls.keyFile}'" + ]); + + # Common HTTP options + mkHttpOptions = defaultPort: { + address = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1:${toString defaultPort}"; + description = "HTTP listen address for the MCP server."; + }; + + endpoint = lib.mkOption { + type = lib.types.str; + default = "/mcp"; + description = "HTTP endpoint path for MCP requests."; + }; + + allowedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "http://localhost:3000" "https://example.com" ]; + description = '' + Allowed Origin headers for CORS. + Empty list means only localhost origins are allowed. + ''; + }; + + sessionTTL = lib.mkOption { + type = lib.types.str; + default = "30m"; + description = "Session TTL for HTTP transport (Go duration format)."; + }; + + tls = { + enable = lib.mkEnableOption "TLS for HTTP transport"; + + certFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to TLS certificate file."; + }; + + keyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to TLS private key file."; + }; + }; + }; + + # Service configuration factory + mkServiceConfig = serviceName: subcommand: httpCfg: { + description = "Nixpkgs Search ${serviceName} MCP Server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ] + ++ lib.optional (cfg.database.type == "postgres") "postgresql.service"; + + environment = lib.mkIf (!useConnectionStringFile) { + NIXPKGS_SEARCH_DATABASE = databaseUrl; + }; + + path = [ cfg.package ]; + + script = let + httpFlags = mkHttpFlags httpCfg; + in + if useConnectionStringFile then '' + # Read database connection string from file + if [ ! -f "${cfg.database.connectionStringFile}" ]; then + echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2 + exit 1 + fi + export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")" + + exec nixpkgs-search ${subcommand} serve ${httpFlags} + '' else '' + exec nixpkgs-search ${subcommand} serve ${httpFlags} + ''; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + RestartSec = "5s"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + + ReadWritePaths = [ cfg.dataDir ]; + WorkingDirectory = cfg.dataDir; + StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/nixpkgs-search") "nixpkgs-search"; + }; + }; +in +{ + options.services.nixpkgs-search = { + enable = lib.mkEnableOption "Nixpkgs Search MCP server(s)"; + + package = lib.mkPackageOption pkgs "nixpkgs-search" { }; + + user = lib.mkOption { + type = lib.types.str; + default = "nixpkgs-search"; + description = "User account under which the service runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "nixpkgs-search"; + description = "Group under which the service runs."; + }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/nixpkgs-search"; + description = "Directory to store data files."; + }; + + database = { + type = lib.mkOption { + type = lib.types.enum [ "sqlite" "postgres" ]; + default = "sqlite"; + description = "Database backend to use."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "nixpkgs-search.db"; + description = "SQLite database filename (when using sqlite backend)."; + }; + + connectionString = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + PostgreSQL connection string (when using postgres backend). + Example: "postgres://user:password@localhost/nixpkgs_search?sslmode=disable" + + WARNING: This value will be stored in the Nix store, which is world-readable. + For production use with sensitive credentials, use connectionStringFile instead. + ''; + }; + + connectionStringFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to a file containing the PostgreSQL connection string. + The file should contain just the connection string, e.g.: + postgres://user:password@localhost/nixpkgs_search?sslmode=disable + + This is the recommended way to configure PostgreSQL credentials + as the file is not stored in the world-readable Nix store. + The file must be readable by the service user. + ''; + example = "/run/secrets/nixpkgs-search-db"; + }; + }; + + indexOnStart = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "nixos-unstable" "nixos-24.11" ]; + description = '' + List of nixpkgs revisions to index on service start. + Can be channel names (nixos-unstable) or git hashes. + Indexing is skipped if the revision is already indexed. + ''; + }; + + indexFlags = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "--no-packages" "--no-files" ]; + description = '' + Additional flags to pass to the index command. + Useful for skipping packages (--no-packages), options (--no-options), + or files (--no-files) during indexing. + ''; + }; + + options = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable the NixOS options MCP server."; + }; + + http = mkHttpOptions 8082; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the options MCP HTTP server."; + }; + }; + + packages = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable the Nix packages MCP server."; + }; + + http = mkHttpOptions 8083; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the packages MCP HTTP server."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.database.type == "sqlite" + || cfg.database.connectionString != "" + || cfg.database.connectionStringFile != null; + message = "services.nixpkgs-search.database: when using postgres backend, either connectionString or connectionStringFile must be set"; + } + { + assertion = cfg.database.connectionString == "" || cfg.database.connectionStringFile == null; + message = "services.nixpkgs-search.database: connectionString and connectionStringFile are mutually exclusive"; + } + { + assertion = !cfg.options.http.tls.enable || (cfg.options.http.tls.certFile != null && cfg.options.http.tls.keyFile != null); + message = "services.nixpkgs-search.options.http.tls: both certFile and keyFile must be set when TLS is enabled"; + } + { + assertion = !cfg.packages.http.tls.enable || (cfg.packages.http.tls.certFile != null && cfg.packages.http.tls.keyFile != null); + message = "services.nixpkgs-search.packages.http.tls: both certFile and keyFile must be set when TLS is enabled"; + } + { + assertion = cfg.options.enable || cfg.packages.enable; + message = "services.nixpkgs-search: at least one of options.enable or packages.enable must be true"; + } + ]; + + users.users.${cfg.user} = lib.mkIf (cfg.user == "nixpkgs-search") { + isSystemUser = true; + group = cfg.group; + home = cfg.dataDir; + description = "Nixpkgs Search MCP server user"; + }; + + users.groups.${cfg.group} = lib.mkIf (cfg.group == "nixpkgs-search") { }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" + ]; + + # Indexing service (runs once on startup if indexOnStart is set) + systemd.services.nixpkgs-search-index = lib.mkIf (cfg.indexOnStart != []) { + description = "Nixpkgs Search Indexer"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ] + ++ lib.optional (cfg.database.type == "postgres") "postgresql.service"; + before = lib.optionals cfg.options.enable [ "nixpkgs-search-options.service" ] + ++ lib.optionals cfg.packages.enable [ "nixpkgs-search-packages.service" ]; + + environment = lib.mkIf (!useConnectionStringFile) { + NIXPKGS_SEARCH_DATABASE = databaseUrl; + }; + + path = [ cfg.package ]; + + script = let + indexFlags = lib.concatStringsSep " " cfg.indexFlags; + indexCommands = lib.concatMapStringsSep "\n" (rev: '' + echo "Indexing revision: ${rev}" + nixpkgs-search index ${indexFlags} "${rev}" || true + '') cfg.indexOnStart; + in + if useConnectionStringFile then '' + # Read database connection string from file + if [ ! -f "${cfg.database.connectionStringFile}" ]; then + echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&2 + exit 1 + fi + export NIXPKGS_SEARCH_DATABASE="$(cat "${cfg.database.connectionStringFile}")" + + ${indexCommands} + '' else '' + ${indexCommands} + ''; + + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + RemainAfterExit = true; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + ReadWritePaths = [ cfg.dataDir ]; + WorkingDirectory = cfg.dataDir; + StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/nixpkgs-search") "nixpkgs-search"; + }; + }; + + # Options MCP server + systemd.services.nixpkgs-search-options = lib.mkIf cfg.options.enable + (mkServiceConfig "Options" "options" cfg.options.http // { + after = (mkServiceConfig "Options" "options" cfg.options.http).after + ++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ]; + }); + + # Packages MCP server + systemd.services.nixpkgs-search-packages = lib.mkIf cfg.packages.enable + (mkServiceConfig "Packages" "packages" cfg.packages.http // { + after = (mkServiceConfig "Packages" "packages" cfg.packages.http).after + ++ lib.optionals (cfg.indexOnStart != []) [ "nixpkgs-search-index.service" ]; + }); + + # Open firewall ports if configured + networking.firewall = lib.mkMerge [ + (lib.mkIf cfg.options.openFirewall (let + addressParts = lib.splitString ":" cfg.options.http.address; + port = lib.toInt (lib.last addressParts); + in { + allowedTCPPorts = [ port ]; + })) + (lib.mkIf cfg.packages.openFirewall (let + addressParts = lib.splitString ":" cfg.packages.http.address; + port = lib.toInt (lib.last addressParts); + in { + allowedTCPPorts = [ port ]; + })) + ]; + }; +}