From 5cd09e28ae7de52f6cf5c2a3756f969effe35288 Mon Sep 17 00:00:00 2001 From: Ivan Mincik Date: Wed, 18 Jun 2025 14:47:41 +0200 Subject: [PATCH] nixos/modules: add nominatim module and test --- nixos/modules/module-list.nix | 1 + nixos/modules/services/search/nominatim.nix | 324 ++++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/nominatim.nix | 187 +++++++++++ 4 files changed, 513 insertions(+) create mode 100644 nixos/modules/services/search/nominatim.nix create mode 100644 nixos/tests/nominatim.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index d0831c02e6d7..2bb58bdc01eb 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1413,6 +1413,7 @@ ./services/search/hound.nix ./services/search/manticore.nix ./services/search/meilisearch.nix + ./services/search/nominatim.nix ./services/search/opensearch.nix ./services/search/qdrant.nix ./services/search/quickwit.nix diff --git a/nixos/modules/services/search/nominatim.nix b/nixos/modules/services/search/nominatim.nix new file mode 100644 index 000000000000..5701fcc18650 --- /dev/null +++ b/nixos/modules/services/search/nominatim.nix @@ -0,0 +1,324 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.services.nominatim; + + localDb = cfg.database.host == "localhost"; + uiPackage = cfg.ui.package.override { customConfig = cfg.ui.config; }; +in +{ + options.services.nominatim = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to enable nominatim. + + Also enables nginx virtual host management. Further nginx configuration + can be done by adapting `services.nginx.virtualHosts.`. + See [](#opt-services.nginx.virtualHosts). + ''; + }; + + package = lib.mkPackageOption pkgs.python3Packages "nominatim-api" { }; + + hostName = lib.mkOption { + type = lib.types.str; + description = "Hostname to use for the nginx vhost."; + example = "nominatim.example.com"; + }; + + settings = lib.mkOption { + default = { }; + type = lib.types.attrsOf lib.types.str; + example = lib.literalExpression '' + { + NOMINATIM_REPLICATION_URL = "https://planet.openstreetmap.org/replication/minute"; + NOMINATIM_REPLICATION_MAX_DIFF = "100"; + } + ''; + description = '' + Nominatim configuration settings. + For the list of available configuration options see + . + ''; + }; + + ui = { + package = lib.mkPackageOption pkgs "nominatim-ui" { }; + + config = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Nominatim UI configuration placed to theme/config.theme.js file. + + For the list of available configuration options see + . + ''; + example = '' + Nominatim_Config.Page_Title='My Nominatim instance'; + Nominatim_Config.Nominatim_API_Endpoint='https://localhost/'; + ''; + }; + }; + + database = { + host = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = '' + Host of the postgresql server. If not set to `localhost`, Nominatim + database and postgresql superuser with appropriate permissions must + exist on target host. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 5432; + description = "Port of the postgresql database."; + }; + + dbname = lib.mkOption { + type = lib.types.str; + default = "nominatim"; + description = "Name of the postgresql database."; + }; + + superUser = lib.mkOption { + type = lib.types.str; + default = "nominatim"; + description = '' + Postgresql database superuser used to create Nominatim database and + import data. If `database.host` is set to `localhost`, a unix user and + group of the same name will be automatically created. + ''; + }; + + apiUser = lib.mkOption { + type = lib.types.str; + default = "nominatim-api"; + description = '' + Postgresql database user with read-only permissions used for Nominatim + web API service. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Password file used for Nominatim database connection. + Must be readable only for the Nominatim web API user. + + The file must be a valid `.pgpass` file as described in: + + + In most cases, the following will be enough: + ``` + *:*:*:*: + ``` + ''; + }; + + extraConnectionParams = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Extra Nominatim database connection parameters. + + Format: + =;= + + See . + ''; + }; + }; + }; + + config = + let + nominatimSuperUserDsn = + "pgsql:dbname=${cfg.database.dbname};" + + "user=${cfg.database.superUser}" + + lib.optionalString (cfg.database.extraConnectionParams != null) ( + ";" + cfg.database.extraConnectionParams + ); + + nominatimApiDsn = + "pgsql:dbname=${cfg.database.dbname}" + + lib.optionalString (!localDb) ( + ";host=${cfg.database.host};" + + "port=${toString cfg.database.port};" + + "user=${cfg.database.apiUser}" + ) + + lib.optionalString (cfg.database.extraConnectionParams != null) ( + ";" + cfg.database.extraConnectionParams + ); + in + lib.mkIf cfg.enable { + # CLI package + environment.systemPackages = [ pkgs.nominatim ]; + + # Database + users.users.${cfg.database.superUser} = lib.mkIf localDb { + group = cfg.database.superUser; + isSystemUser = true; + createHome = false; + }; + users.groups.${cfg.database.superUser} = lib.mkIf localDb { }; + + services.postgresql = lib.mkIf localDb { + enable = true; + extensions = ps: with ps; [ postgis ]; + ensureUsers = [ + { + name = cfg.database.superUser; + ensureClauses.superuser = true; + } + { + name = cfg.database.apiUser; + } + ]; + }; + + # TODO: add nominatim-update service + + systemd.services.nominatim-init = lib.mkIf localDb { + after = [ "postgresql-setup.service" ]; + requires = [ "postgresql-setup.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.database.superUser; + RemainAfterExit = true; + PrivateTmp = true; + }; + script = '' + sql="SELECT COUNT(*) FROM pg_database WHERE datname='${cfg.database.dbname}'" + db_exists=$(${pkgs.postgresql}/bin/psql --dbname postgres -tAc "$sql") + + if [ "$db_exists" == "0" ]; then + ${lib.getExe pkgs.nominatim} import --prepare-database + else + echo "Database ${cfg.database.dbname} already exists. Skipping ..." + fi + ''; + path = [ + pkgs.postgresql + ]; + environment = { + NOMINATIM_DATABASE_DSN = nominatimSuperUserDsn; + NOMINATIM_DATABASE_WEBUSER = cfg.database.apiUser; + } // cfg.settings; + }; + + # Web API service + users.users.${cfg.database.apiUser} = { + group = cfg.database.apiUser; + isSystemUser = true; + createHome = false; + }; + users.groups.${cfg.database.apiUser} = { }; + + systemd.services.nominatim = { + after = [ "network.target" ] ++ lib.optionals localDb [ "nominatim-init.service" ]; + requires = lib.optionals localDb [ "nominatim-init.service" ]; + bindsTo = lib.optionals localDb [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + wants = [ "network.target" ]; + serviceConfig = { + Type = "simple"; + User = cfg.database.apiUser; + ExecStart = '' + ${pkgs.python3Packages.gunicorn}/bin/gunicorn \ + --bind unix:/run/nominatim.sock \ + --workers 4 \ + --worker-class uvicorn.workers.UvicornWorker "nominatim_api.server.falcon.server:run_wsgi()" + ''; + Environment = lib.optional ( + cfg.database.passwordFile != null + ) "PGPASSFILE=${cfg.database.passwordFile}"; + ExecReload = "${pkgs.procps}/bin/kill -s HUP $MAINPID"; + KillMode = "mixed"; + TimeoutStopSec = 5; + }; + environment = { + PYTHONPATH = + with pkgs.python3Packages; + pkgs.python3Packages.makePythonPath [ + cfg.package + falcon + uvicorn + ]; + NOMINATIM_DATABASE_DSN = nominatimApiDsn; + NOMINATIM_DATABASE_WEBUSER = cfg.database.apiUser; + } // cfg.settings; + }; + + systemd.sockets.nominatim = { + before = [ "nominatim.service" ]; + wantedBy = [ "sockets.target" ]; + socketConfig = { + ListenStream = "/run/nominatim.sock"; + SocketUser = cfg.database.apiUser; + SocketGroup = config.services.nginx.group; + }; + }; + + services.nginx = { + enable = true; + appendHttpConfig = '' + map $args $format { + default default; + ~(^|&)format=html(&|$) html; + } + + map $uri/$format $forward_to_ui { + default 0; # No forwarding by default. + + # Redirect to HTML UI if explicitly requested. + ~/reverse.*/html 1; + ~/search.*/html 1; + ~/lookup.*/html 1; + ~/details.*/html 1; + } + ''; + upstreams.nominatim = { + servers = { + "unix:/run/nominatim.sock" = { }; + }; + }; + virtualHosts = { + ${cfg.hostName} = { + forceSSL = lib.mkDefault true; + enableACME = lib.mkDefault true; + locations = { + "= /" = { + extraConfig = '' + return 301 $scheme://$http_host/ui/search.html; + ''; + }; + "/" = { + proxyPass = "http://nominatim"; + extraConfig = '' + if ($forward_to_ui) { + rewrite ^(/[^/.]*) /ui$1.html redirect; + } + ''; + }; + "/ui/" = { + alias = "${uiPackage}/"; + }; + }; + }; + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index d370a4180286..c4c8c38c1fd4 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1012,6 +1012,7 @@ in nixseparatedebuginfod = runTest ./nixseparatedebuginfod.nix; node-red = runTest ./node-red.nix; nomad = runTest ./nomad.nix; + nominatim = runTest ./nominatim.nix; non-default-filesystems = handleTest ./non-default-filesystems.nix { }; non-switchable-system = runTest ./non-switchable-system.nix; noto-fonts = runTest ./noto-fonts.nix; diff --git a/nixos/tests/nominatim.nix b/nixos/tests/nominatim.nix new file mode 100644 index 000000000000..3919f245abd1 --- /dev/null +++ b/nixos/tests/nominatim.nix @@ -0,0 +1,187 @@ +{ pkgs, lib, ... }: + +let + # Andorra - the smallest dataset in Europe (3.1 MB) + osmData = pkgs.fetchurl { + url = "https://web.archive.org/web/20250430211212/https://download.geofabrik.de/europe/andorra-latest.osm.pbf"; + hash = "sha256-Ey+ipTOFUm80rxBteirPW5N4KxmUsg/pCE58E/2rcyE="; + }; +in +{ + name = "nominatim"; + meta = { + maintainers = with lib.teams; [ + geospatial + ngi + ]; + }; + + nodes = { + # nominatim - self contained host + nominatim = + { config, pkgs, ... }: + { + # Nominatim + services.nominatim = { + enable = true; + hostName = "nominatim"; + settings = { + NOMINATIM_IMPORT_STYLE = "admin"; + }; + ui = { + config = '' + Nominatim_Config.Page_Title='Test Nominatim instance'; + Nominatim_Config.Nominatim_API_Endpoint='https://localhost/'; + ''; + }; + }; + + # Disable SSL + services.nginx.virtualHosts.nominatim = { + forceSSL = false; + enableACME = false; + }; + + # Database + services.postgresql = { + enableTCPIP = true; + authentication = lib.mkForce '' + local all all trust + host all all 0.0.0.0/0 md5 + host all all ::0/0 md5 + ''; + }; + systemd.services.postgresql-setup.postStart = '' + psql --command "ALTER ROLE \"nominatim-api\" WITH PASSWORD 'password';" + ''; + networking.firewall.allowedTCPPorts = [ config.services.postgresql.settings.port ]; + }; + + # api - web API only + api = + { config, pkgs, ... }: + { + # Database password + system.activationScripts = { + passwordFile.text = with config.services.nominatim.database; '' + mkdir -p /run/secrets + echo "${host}:${toString port}:${dbname}:${apiUser}:password" \ + > /run/secrets/pgpass + chown nominatim-api:nominatim-api /run/secrets/pgpass + chmod 0600 /run/secrets/pgpass + ''; + }; + + # Nominatim + services.nominatim = { + enable = true; + hostName = "nominatim"; + settings = { + NOMINATIM_LOG_DB = "yes"; + }; + database = { + host = "nominatim"; + passwordFile = "/run/secrets/pgpass"; + extraConnectionParams = "application_name=nominatim;connect_timeout=2"; + }; + }; + + # Disable SSL + services.nginx.virtualHosts.nominatim = { + forceSSL = false; + enableACME = false; + }; + }; + }; + + testScript = '' + # Test nominatim host + nominatim.start() + nominatim.wait_for_unit("nominatim.service") + + # Import OSM data + nominatim.succeed(""" + cd /tmp + sudo -u nominatim \ + NOMINATIM_DATABASE_WEBUSER=nominatim-api \ + NOMINATIM_IMPORT_STYLE=admin \ + nominatim import --continue import-from-file --osm-file ${osmData} + """) + nominatim.succeed("systemctl restart nominatim.service") + + # Test CLI + nominatim.succeed("sudo -u nominatim-api nominatim search --query Andorra") + + # Test web API + nominatim.succeed("curl 'http://localhost/status' | grep OK") + + nominatim.succeed(""" + curl "http://localhost/search?q=Andorra&format=geojson" | grep "Andorra" + curl "http://localhost/reverse?lat=42.5407167&lon=1.5732033&format=geojson" + """) + + # Test UI + nominatim.succeed(""" + curl "http://localhost/ui/search.html" \ + | grep "Nominatim Demo" + """) + + + # Test api host + api.start() + api.wait_for_unit("nominatim.service") + + # Test web API + api.succeed(""" + curl "http://localhost/search?q=Andorra&format=geojson" | grep "Andorra" + curl "http://localhost/reverse?lat=42.5407167&lon=1.5732033&format=geojson" + """) + + + # Test format rewrites + # Redirect / to search + nominatim.succeed(""" + curl --verbose "http://localhost" 2>&1 \ + | grep "Location: http://localhost/ui/search.html" + """) + + # Return text by default + nominatim.succeed(""" + curl --verbose "http://localhost/status" 2>&1 \ + | grep "Content-Type: text/plain" + """) + + # Return JSON by default + nominatim.succeed(""" + curl --verbose "http://localhost/search?q=Andorra" 2>&1 \ + | grep "Content-Type: application/json" + """) + + # Return XML by default + nominatim.succeed(""" + curl --verbose "http://localhost/lookup" 2>&1 \ + | grep "Content-Type: text/xml" + + curl --verbose "http://localhost/reverse?lat=0&lon=0" 2>&1 \ + | grep "Content-Type: text/xml" + """) + + # Redirect explicitly requested HTML format + nominatim.succeed(""" + curl --verbose "http://localhost/search?format=html" 2>&1 \ + | grep "Location: http://localhost/ui/search.html" + + curl --verbose "http://localhost/reverse?format=html" 2>&1 \ + | grep "Location: http://localhost/ui/reverse.html" + """) + + # Return explicitly requested JSON format + nominatim.succeed(""" + curl --verbose "http://localhost/search?format=json" 2>&1 \ + | grep "Content-Type: application/json" + + curl --verbose "http://localhost/reverse?format=json" 2>&1 \ + | grep "Content-Type: application/json" + """) + ''; +}