commit 0b3e086c0368455af3d27e21123ba62c62222c1b Author: shokinn Date: Thu Jul 3 17:35:13 2025 +0200 Initial Bitpoll Nix package and service diff --git a/README.md b/README.md new file mode 100644 index 0000000..09bba40 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# Bitpoll Nix Package + +This repository contains a Nix flake for packaging [Bitpoll](https://github.com/fsinfuhh/Bitpoll), a web application for scheduling meetings and general polling. + +## Features + +- **Complete Nix Package**: Bitpoll packaged as a Nix derivation with all Python dependencies +- **NixOS Service Module**: Ready-to-use systemd service with PostgreSQL integration +- **Security Hardened**: Runs with minimal privileges and security restrictions +- **Configurable**: All major settings exposed as NixOS options +- **Production Ready**: Uses uWSGI with proper process management + +## Quick Start + +### 1. Add to your NixOS configuration + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + bitpoll.url = "github:your-username/bitpoll-nix"; + }; + + outputs = { self, nixpkgs, bitpoll }: { + nixosConfigurations.your-host = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + bitpoll.nixosModules.default + { + services.bitpoll = { + enable = true; + secretKey = "your-secret-key-here"; + encryptionKey = "your-encryption-key-here"; + allowedHosts = [ "your-domain.com" ]; + }; + } + ]; + }; + }; +} +``` + +### 2. Generate required keys + +```bash +# Generate Django secret key +python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" + +# Generate field encryption key (32 bytes, base64 encoded) +python -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())" +``` + +### 3. Deploy + +```bash +sudo nixos-rebuild switch --flake .#your-host +``` + +## Configuration Options + +### Basic Configuration + +```nix +services.bitpoll = { + enable = true; + + # Required security keys + secretKey = "your-django-secret-key"; + encryptionKey = "your-field-encryption-key"; + + # Network settings + listenAddress = "127.0.0.1"; + port = 3008; # uWSGI socket + httpPort = 3009; # HTTP port (null to disable) + + # Django settings + debug = false; + allowedHosts = [ "your-domain.com" ]; + language = "en-us"; + timezone = "Europe/Berlin"; +}; +``` + +### Database Configuration + +```nix +services.bitpoll = { + # PostgreSQL is enabled by default + enablePostgreSQL = true; + + database = { + name = "bitpoll"; + user = "bitpoll"; + password = ""; # Leave empty for peer authentication + host = "localhost"; + port = 5432; + }; +}; +``` + +### Performance Tuning + +```nix +services.bitpoll = { + # uWSGI process management + processes = 8; # Max processes + threads = 4; # Threads per process + cheaperProcesses = 2; # Min processes + + # Additional uWSGI configuration + extraUwsgiConfig = '' + max-requests = 1000 + reload-on-rss = 512 + ''; +}; +``` + +### Advanced Settings + +```nix +services.bitpoll = { + # Additional Django settings + extraSettings = { + PIPELINE_LOCAL = { + JS_COMPRESSOR = "pipeline.compressors.uglifyjs.UglifyJSCompressor"; + CSS_COMPRESSOR = "pipeline.compressors.cssmin.CSSMinCompressor"; + }; + CSP_ADDITIONAL_SCRIPT_SRC = [ "your-analytics-domain.com" ]; + }; +}; +``` + +## Reverse Proxy Setup + +### Nginx Example + +```nix +services.nginx = { + enable = true; + virtualHosts."your-domain.com" = { + enableACME = true; + forceSSL = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:3009"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + "/static/" = { + alias = "/var/lib/bitpoll/static/"; + extraConfig = '' + expires 1y; + add_header Cache-Control "public, immutable"; + ''; + }; + }; + }; +}; +``` + +## Data Storage + +All persistent data is stored in `/var/lib/bitpoll/`: +- `media/` - User uploaded files +- `static/` - Collected static files +- Database data (if using PostgreSQL, stored in PostgreSQL data directory) + +## Security + +The service runs with extensive security hardening: +- Dedicated user account (`bitpoll`) +- Restricted filesystem access +- No network access except required ports +- Memory execution protection +- System call filtering + +## Development + +### Building the package + +```bash +nix build .#bitpoll +``` + +### Development shell + +```bash +nix develop +``` + +### Testing the module + +```bash +nixos-rebuild build-vm --flake .#test-vm +``` + +## Troubleshooting + +### Check service status + +```bash +systemctl status bitpoll +journalctl -u bitpoll -f +``` + +### Database issues + +```bash +# Check PostgreSQL status +systemctl status postgresql + +# Connect to database +sudo -u postgres psql bitpoll +``` + +### Permission issues + +```bash +# Fix data directory permissions +sudo chown -R bitpoll:bitpoll /var/lib/bitpoll +sudo chmod -R u=rwX,g=rX,o= /var/lib/bitpoll +``` + +## License + +This packaging is released under the same license as Bitpoll (GPL-3.0). diff --git a/example-configuration.nix b/example-configuration.nix new file mode 100644 index 0000000..a8b0e51 --- /dev/null +++ b/example-configuration.nix @@ -0,0 +1,160 @@ +# Example NixOS configuration for Bitpoll +{ config, pkgs, ... }: + +{ + imports = [ + # Import the Bitpoll module + ./module.nix + ]; + + # Enable Bitpoll service + services.bitpoll = { + enable = true; + + # Required security keys (generate these!) + secretKey = "CHANGE-ME-django-secret-key-here"; + encryptionKey = "CHANGE-ME-field-encryption-key-here"; + + # Network configuration + listenAddress = "127.0.0.1"; + port = 3008; # uWSGI socket port + httpPort = 3009; # HTTP port for direct access + + # Django settings + debug = false; + allowedHosts = [ "localhost" "bitpoll.example.com" ]; + language = "en-us"; + timezone = "Europe/Berlin"; + + # Database configuration (PostgreSQL is auto-configured) + database = { + name = "bitpoll"; + user = "bitpoll"; + password = ""; # Empty for peer authentication + host = "localhost"; + port = 5432; + }; + + # Performance settings + processes = 4; # Adjust based on your server + threads = 2; + cheaperProcesses = 1; + + # Additional Django settings + extraSettings = { + # Pipeline configuration for asset compression + PIPELINE_LOCAL = { + JS_COMPRESSOR = "pipeline.compressors.uglifyjs.UglifyJSCompressor"; + CSS_COMPRESSOR = "pipeline.compressors.cssmin.CSSMinCompressor"; + }; + + # Content Security Policy + CSP_ADDITIONAL_SCRIPT_SRC = [ ]; + + # Additional installed apps (if needed) + INSTALLED_APPS_LOCAL = [ ]; + }; + + # Additional uWSGI configuration + extraUwsgiConfig = '' + # Reload workers after 1000 requests to prevent memory leaks + max-requests = 1000 + + # Reload if memory usage exceeds 512MB + reload-on-rss = 512 + + # Enable stats server (optional, for monitoring) + # stats = 127.0.0.1:9191 + ''; + }; + + # Nginx reverse proxy configuration + services.nginx = { + enable = true; + + virtualHosts."bitpoll.example.com" = { + # Enable HTTPS with Let's Encrypt + enableACME = true; + forceSSL = true; + + locations = { + # Proxy all requests to Bitpoll + "/" = { + proxyPass = "http://127.0.0.1:3009"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Increase timeouts for long-running requests + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + ''; + }; + + # Serve static files directly from Nginx for better performance + "/static/" = { + alias = "/var/lib/bitpoll/static/"; + extraConfig = '' + expires 1y; + add_header Cache-Control "public, immutable"; + gzip on; + gzip_types text/css application/javascript application/json; + ''; + }; + + # Serve media files (user uploads) + "/media/" = { + alias = "/var/lib/bitpoll/media/"; + extraConfig = '' + expires 1d; + add_header Cache-Control "public"; + ''; + }; + }; + }; + }; + + # ACME configuration for Let's Encrypt + security.acme = { + acceptTerms = true; + defaults.email = "admin@example.com"; + }; + + # Firewall configuration + networking.firewall = { + enable = true; + allowedTCPPorts = [ 80 443 ]; + }; + + # Optional: Backup configuration + services.restic.backups.bitpoll = { + initialize = true; + repository = "/backup/bitpoll"; + passwordFile = "/etc/nixos/secrets/restic-password"; + paths = [ "/var/lib/bitpoll" ]; + timerConfig = { + OnCalendar = "daily"; + Persistent = true; + }; + }; + + # Optional: Log rotation + services.logrotate = { + enable = true; + settings = { + "/var/log/bitpoll/*.log" = { + frequency = "daily"; + rotate = 30; + compress = true; + delaycompress = true; + missingok = true; + notifempty = true; + create = "644 bitpoll bitpoll"; + }; + }; + }; +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f7c6533 --- /dev/null +++ b/flake.nix @@ -0,0 +1,42 @@ +{ + description = "Bitpoll - A web application for scheduling meetings and general polling"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + bitpoll = pkgs.callPackage ./package.nix { }; + in + { + packages = { + default = bitpoll; + bitpoll = bitpoll; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + python3 + python3Packages.pip + python3Packages.virtualenv + postgresql + uwsgi + ]; + shellHook = '' + echo "Bitpoll development environment" + echo "Run 'nix build' to build the package" + echo "Run 'nixos-rebuild switch --flake .#' to deploy the service" + ''; + }; + } + ) // { + nixosModules = { + default = import ./module.nix; + bitpoll = import ./module.nix; + }; + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..cb84ea8 --- /dev/null +++ b/module.nix @@ -0,0 +1,349 @@ +{ 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 ]; + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..183664e --- /dev/null +++ b/package.nix @@ -0,0 +1,116 @@ +{ lib +, buildPythonApplication +, fetchFromGitHub +, python3Packages +, gettext +, libsass +, pkg-config +, postgresql +, uwsgi +}: + +buildPythonApplication rec { + pname = "bitpoll"; + version = "unstable-2024-11-23"; + format = "setuptools"; + + src = fetchFromGitHub { + owner = "fsinfuhh"; + repo = "Bitpoll"; + rev = "4a3e6a5e3500308a428a6c7644f50d423adca6fc"; + hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }; + + nativeBuildInputs = [ + gettext + pkg-config + ]; + + buildInputs = [ + libsass + postgresql + ]; + + propagatedBuildInputs = with python3Packages; [ + # Core Django dependencies + django + django-auth-ldap + django-encrypted-model-fields + django-friendly-tag-loader + django-markdownify + django-pipeline + django-token-bucket + django-widget-tweaks + + # Database + psycopg2 + + # Calendar and date handling + caldav + icalendar + python-dateutil + pytz + recurring-ical-events + x-wr-timezone + + # Authentication and security + simple-openid-connect + cryptography + cryptojwt + + # Utilities + bleach + furl + lxml + markdown + requests + sentry-sdk + + # SASS compilation + libsasscompiler + + # Other dependencies + pydantic + six + vobject + ]; + + # Create a setup.py since the project doesn't have one + preBuild = '' + cat > setup.py << EOF +from setuptools import setup, find_packages + +setup( + name='bitpoll', + version='${version}', + packages=find_packages(), + include_package_data=True, + install_requires=[], + scripts=['manage.py'], + entry_points={ + 'console_scripts': [ + 'bitpoll-manage=manage:main', + ], + }, +) +EOF + ''; + + # Compile messages and collect static files + postBuild = '' + export DJANGO_SETTINGS_MODULE=bitpoll.settings.production + python manage.py compilemessages + python manage.py collectstatic --noinput --clear + ''; + + # Skip tests for now as they require additional setup + doCheck = false; + + meta = with lib; { + description = "A web application for scheduling meetings and general polling"; + homepage = "https://github.com/fsinfuhh/Bitpoll"; + license = licenses.gpl3Only; + maintainers = [ ]; + platforms = platforms.linux; + }; +}