{ config, lib, pkgs, ... }: with lib; let cfg = config.services.bitpoll; # Persistent data directory dataDir = "/var/lib/bitpoll"; # Generate Django settings file settingsFile = pkgs.writeText "bitpoll-settings.py" '' # Bitpoll NixOS Configuration import os from bitpoll.settings.production import * # Security settings SECRET_KEY = '${cfg.secretKey}' FIELD_ENCRYPTION_KEY = '${cfg.encryptionKey}' DEBUG = ${boolToString cfg.debug} ALLOWED_HOSTS = ${builtins.toJSON cfg.allowedHosts} # Localization LANGUAGE_CODE = '${cfg.language}' TIME_ZONE = '${cfg.timezone}' # Database configuration DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': '${cfg.database.name}', 'USER': '${cfg.database.user}', 'PASSWORD': '${cfg.database.password}', 'HOST': '${cfg.database.host}', 'PORT': '${toString cfg.database.port}', } } # File storage paths MEDIA_ROOT = '${dataDir}/media' STATIC_ROOT = '${dataDir}/static' # Additional settings ${concatStringsSep "\n" (mapAttrsToList (k: v: "${k} = ${builtins.toJSON v}") cfg.extraSettings)} ''; # Generate uWSGI configuration uwsgiConfig = pkgs.writeText "uwsgi.ini" '' [uwsgi] procname-master = uwsgi bitpoll master = true socket = ${cfg.listenAddress}:${toString cfg.port} ${optionalString (cfg.httpPort != null) "http-socket = ${cfg.listenAddress}:${toString cfg.httpPort}"} plugins = python3 chdir = ${dataDir} virtualenv = ${cfg.package} pythonpath = ${cfg.package}/lib/python*/site-packages module = bitpoll.wsgi:application env = DJANGO_SETTINGS_MODULE=bitpoll.settings env = BITPOLL_SETTINGS_FILE=${settingsFile} env = LANG=C.UTF-8 env = LC_ALL=C.UTF-8 # Process management uid = ${cfg.user} gid = ${cfg.group} umask = 027 processes = ${toString cfg.processes} threads = ${toString cfg.threads} cheaper = ${toString cfg.cheaperProcesses} # Logging disable-logging = ${boolToString cfg.disableLogging} # Static files static-map = /static=${dataDir}/static # Additional uWSGI options ${cfg.extraUwsgiConfig} ''; in { options.services.bitpoll = { enable = mkEnableOption "Bitpoll polling application"; package = mkOption { type = types.package; default = pkgs.bitpoll or (pkgs.callPackage ./package.nix { }); description = "The Bitpoll package to use"; }; user = mkOption { type = types.str; default = "bitpoll"; description = "User account under which Bitpoll runs"; }; group = mkOption { type = types.str; default = "bitpoll"; description = "Group under which Bitpoll runs"; }; listenAddress = mkOption { type = types.str; default = "127.0.0.1"; description = "Address to listen on"; }; port = mkOption { type = types.port; default = 3008; description = "Port for uWSGI socket"; }; httpPort = mkOption { type = types.nullOr types.port; default = 3009; description = "Port for HTTP socket (null to disable)"; }; # Django settings secretKey = mkOption { type = types.str; description = "Django secret key"; example = "your-secret-key-here"; }; encryptionKey = mkOption { type = types.str; description = "Field encryption key"; example = "BnEAJ5eEXb4HfYbaCPuW5RKQSoO02Uhz1RH93eQz0GM="; }; debug = mkOption { type = types.bool; default = false; description = "Enable Django debug mode"; }; allowedHosts = mkOption { type = types.listOf types.str; default = [ "*" ]; description = "List of allowed hosts"; }; language = mkOption { type = types.str; default = "en-us"; description = "Language code"; }; timezone = mkOption { type = types.str; default = "Europe/Berlin"; description = "Time zone"; }; # Database settings database = { name = mkOption { type = types.str; default = "bitpoll"; description = "Database name"; }; user = mkOption { type = types.str; default = "bitpoll"; description = "Database user"; }; password = mkOption { type = types.str; default = ""; description = "Database password"; }; host = mkOption { type = types.str; default = "localhost"; description = "Database host"; }; port = mkOption { type = types.port; default = 5432; description = "Database port"; }; }; # uWSGI settings processes = mkOption { type = types.int; default = 8; description = "Number of uWSGI processes"; }; threads = mkOption { type = types.int; default = 4; description = "Number of threads per process"; }; cheaperProcesses = mkOption { type = types.int; default = 2; description = "Minimum number of processes"; }; disableLogging = mkOption { type = types.bool; default = true; description = "Disable uWSGI request logging"; }; extraUwsgiConfig = mkOption { type = types.lines; default = ""; description = "Additional uWSGI configuration"; }; extraSettings = mkOption { type = types.attrsOf types.anything; default = { }; description = "Additional Django settings"; }; # PostgreSQL integration enablePostgreSQL = mkOption { type = types.bool; default = true; description = "Enable and configure PostgreSQL"; }; }; config = mkIf cfg.enable { # PostgreSQL setup services.postgresql = mkIf cfg.enablePostgreSQL { enable = true; ensureDatabases = [ cfg.database.name ]; ensureUsers = [{ name = cfg.database.user; ensureDBOwnership = true; }]; }; # Create user and group users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; home = dataDir; createHome = true; }; users.groups.${cfg.group} = { }; # Create data directories systemd.tmpfiles.rules = [ "d ${dataDir} 0750 ${cfg.user} ${cfg.group} -" "d ${dataDir}/media 0750 ${cfg.user} ${cfg.group} -" "d ${dataDir}/static 0750 ${cfg.user} ${cfg.group} -" ]; # Bitpoll service systemd.services.bitpoll = { description = "Bitpoll polling application"; after = [ "network.target" ] ++ optional cfg.enablePostgreSQL "postgresql.service"; wants = optional cfg.enablePostgreSQL "postgresql.service"; wantedBy = [ "multi-user.target" ]; environment = { DJANGO_SETTINGS_MODULE = "bitpoll.settings"; BITPOLL_SETTINGS_FILE = "${settingsFile}"; PYTHONPATH = "${cfg.package}/lib/python*/site-packages"; }; serviceConfig = { Type = "notify"; User = cfg.user; Group = cfg.group; WorkingDirectory = dataDir; ExecStart = "${pkgs.uwsgi}/bin/uwsgi --ini ${uwsgiConfig}"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; KillMode = "mixed"; KillSignal = "SIGINT"; PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; ReadWritePaths = [ dataDir ]; NoNewPrivileges = true; # Security hardening CapabilityBoundingSet = ""; DeviceAllow = ""; LockPersonality = true; MemoryDenyWriteExecute = true; PrivateDevices = true; ProtectClock = true; ProtectControlGroups = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; ProcSubset = "pid"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged @resources" ]; UMask = "0027"; }; # Pre-start script for migrations and static files preStart = '' # Wait for database to be ready ${optionalString cfg.enablePostgreSQL '' while ! ${pkgs.postgresql}/bin/pg_isready -h ${cfg.database.host} -p ${toString cfg.database.port} -U ${cfg.database.user} -d ${cfg.database.name}; do echo "Waiting for PostgreSQL..." sleep 2 done ''} # Run migrations cd ${dataDir} export DJANGO_SETTINGS_MODULE=bitpoll.settings export BITPOLL_SETTINGS_FILE=${settingsFile} export PYTHONPATH=${cfg.package}/lib/python*/site-packages ${cfg.package}/bin/python ${cfg.package}/manage.py migrate --noinput ${cfg.package}/bin/python ${cfg.package}/manage.py collectstatic --noinput --clear # Set proper permissions chown -R ${cfg.user}:${cfg.group} ${dataDir} chmod -R u=rwX,g=rX,o= ${dataDir} ''; }; # Open firewall ports if needed networking.firewall.allowedTCPPorts = mkIf (cfg.httpPort != null) [ cfg.httpPort ]; }; }