{ config, lib, pkgs, ... }: { options.services = { panoramax = { enable = lib.mkEnableOption "panoramax"; package = lib.mkOption { type = lib.types.package; default = pkgs.panoramax; description = "The panoramax package to use"; }; user = lib.mkOption { type = lib.types.str; default = "panoramax"; description = "The user panoramax should run as."; }; group = lib.mkOption { type = lib.types.str; default = "panoramax"; description = "The group panoramax should run as."; }; host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "Host to bind the panoramax service to"; }; port = lib.mkOption { type = lib.types.nullOr lib.types.port; default = 5000; description = "Port for the panoramax service"; }; openFirewall = lib.mkOption { type = lib.types.bool; default = false; description = "Whether to open the panoramax port in the firewall"; }; settings = { urlScheme = lib.mkOption { type = lib.types.enum ["http" "https"]; default = "https"; description = "URL scheme for the application"; }; storage = { fsUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = "/var/lib/panoramax/storage"; description = "File system URL for storage"; }; }; infrastructure = { nbProxies = lib.mkOption { type = lib.types.nullOr lib.types.int; default = 1; description = "Number of proxies in front of the application"; }; }; flask = { secretKey = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Flask secret key for session security"; }; sessionCookieDomain = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Flask session cookie domain"; }; }; api = { pictures = { licenseSpdxId = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "SPDX license identifier for API pictures"; }; licenseUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "License URL for API pictures"; }; }; }; extraEnvironment = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = {}; description = "Additional environment variables"; example = { CUSTOM_SETTING = "value"; DEBUG = "true"; }; }; }; database = { createDB = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to automatically create the database and user"; }; name = lib.mkOption { type = lib.types.str; default = "panoramax"; description = "The name of the panoramax database"; }; host = lib.mkOption { type = lib.types.nullOr lib.types.str; default = "/run/postgresql"; description = "Hostname or address of the postgresql server. If an absolute path is given here, it will be interpreted as a unix socket path."; }; port = lib.mkOption { type = lib.types.nullOr lib.types.port; default = 5432; description = "Port of the postgresql server."; }; user = lib.mkOption { type = lib.types.nullOr lib.types.str; default = "panoramax"; description = "The database user for panoramax."; }; # TODO: password file for external database }; sgblur = { # TODO: configs to bind to sgblur }; }; sgblur = { enable = lib.mkOption { type = lib.types.bool; default = false; description = "Whether to enable sgblur integration for face and license plate blurring"; }; package = lib.mkOption { type = lib.types.package; default = pkgs.sgblur; description = "The sgblur package to use"; }; port = lib.mkOption { type = lib.types.port; default = 8080; description = "Port for the sgblur service"; }; host = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "Host to bind the sgblur service to"; }; url = lib.mkOption { type = lib.types.str; default = "http://127.0.0.1:8080"; description = "URL where sgblur service is accessible"; }; }; }; config = lib.mkIf config.services.panoramax.enable (lib.mkMerge [ { # Create panoramax user and group users.users.${config.services.panoramax.user} = { isSystemUser = true; group = config.services.panoramax.group; home = "/var/lib/panoramax"; createHome = true; }; users.groups.${config.services.panoramax.group} = {}; # Ensure storage directory exists with correct permissions systemd.tmpfiles.rules = [ "d '${config.services.panoramax.settings.storage.fsUrl}' 0755 ${config.services.panoramax.user} ${config.services.panoramax.group} - -" ]; systemd.services.panoramax-api = { description = "Panoramax API server (self hosted map street view)"; after = ["network.target" "postgresql.service"]; wantedBy = ["multi-user.target"]; environment = { # Core Flask configuration FLASK_APP = "geovisio"; # Database configuration DB_HOST = config.services.panoramax.database.host; DB_PORT = toString config.services.panoramax.database.port; DB_USERNAME = config.services.panoramax.database.user; DB_NAME = config.services.panoramax.database.name; # Storage configuration FS_URL = config.services.panoramax.settings.storage.fsUrl; # Infrastructure configuration INFRA_NB_PROXIES = toString config.services.panoramax.settings.infrastructure.nbProxies; # Application configuration PORT = toString config.services.panoramax.port; # Python path to include the panoramax package PYTHONPATH = "${config.services.panoramax.package}/${pkgs.python3.sitePackages}"; } // (lib.optionalAttrs (config.services.panoramax.settings.flask.secretKey != null) { FLASK_SECRET_KEY = config.services.panoramax.settings.flask.secretKey; }) // (lib.optionalAttrs (config.services.panoramax.settings.flask.sessionCookieDomain != null) { FLASK_SESSION_COOKIE_DOMAIN = config.services.panoramax.settings.flask.sessionCookieDomain; }) // (lib.optionalAttrs (config.services.panoramax.settings.api.pictures.licenseSpdxId != null) { API_PICTURES_LICENSE_SPDX_ID = config.services.panoramax.settings.api.pictures.licenseSpdxId; }) // (lib.optionalAttrs (config.services.panoramax.settings.api.pictures.licenseUrl != null) { API_PICTURES_LICENSE_URL = config.services.panoramax.settings.api.pictures.licenseUrl; }) // (lib.optionalAttrs config.services.sgblur.enable { SGBLUR_API_URL = config.services.sgblur.url; }) // config.services.panoramax.settings.extraEnvironment; path = with pkgs; [ (python3.withPackages (ps: with ps; [config.services.panoramax.package waitress])) ]; serviceConfig = { ExecStart = "${pkgs.python3.withPackages (ps: with ps; [config.services.panoramax.package waitress])}/bin/waitress-serve --port ${toString config.services.panoramax.port} --call geovisio:create_app"; User = config.services.panoramax.user; Group = config.services.panoramax.group; WorkingDirectory = "/var/lib/panoramax"; Restart = "always"; RestartSec = 5; # Security hardening PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; ReadWritePaths = [ "/var/lib/panoramax" config.services.panoramax.settings.storage.fsUrl ]; NoNewPrivileges = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; RestrictRealtime = true; RestrictNamespaces = true; LockPersonality = true; MemoryDenyWriteExecute = true; SystemCallArchitectures = "native"; }; }; # Open firewall if requested networking.firewall.allowedTCPPorts = lib.mkIf config.services.panoramax.openFirewall [ config.services.panoramax.port ]; } (lib.mkIf config.services.sgblur.enable { # SGBlur service configuration systemd.services.sgblur = { description = "SGBlur face and license plate blurring service"; after = ["network.target"]; wantedBy = ["multi-user.target"]; path = with pkgs; [ config.services.sgblur.package python3 python3Packages.waitress ]; serviceConfig = { ExecStart = "${pkgs.python3Packages.waitress}/bin/waitress-serve --host ${config.services.sgblur.host} --port ${toString config.services.sgblur.port} src.detect.detect_api:app"; WorkingDirectory = "${config.services.sgblur.package}"; Restart = "always"; RestartSec = 5; # Basic security hardening PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; NoNewPrivileges = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; RestrictRealtime = true; RestrictNamespaces = true; LockPersonality = true; MemoryDenyWriteExecute = true; SystemCallArchitectures = "native"; }; }; networking.firewall.allowedTCPPorts = lib.mkIf config.services.panoramax.openFirewall [ config.services.sgblur.port ]; }) (lib.mkIf config.services.panoramax.database.createDB { services.postgresql = { enable = true; ensureDatabases = lib.mkIf config.services.panoramax.database.createDB [config.services.panoramax.database.name]; ensureUsers = lib.mkIf config.services.panoramax.database.createDB [ { name = config.services.panoramax.database.user; ensureDBOwnership = true; ensureClauses.login = true; } ]; extensions = ps: with ps; [postgis]; }; systemd.services.postgresql.serviceConfig.ExecStartPost = let sqlFile = pkgs.writeText "panoramax-postgis-setup.sql" '' CREATE EXTENSION IF NOT EXISTS postgis; -- TODO: how can we ensure that this runs after the databases have been created -- ALTER DATABASE ${config.services.panoramax.database.name} SET TIMEZONE TO 'UTC'; GRANT SET ON PARAMETER session_replication_role TO ${config.services.panoramax.database.user}; ''; in [ '' ${lib.getExe' config.services.postgresql.package "psql"} -d "${config.services.panoramax.database.user}" -f "${sqlFile}" '' ]; }) ]); }