Add nix-infra-machine

This commit is contained in:
Ruben Hensen
2026-03-15 18:31:53 +01:00
parent 28ea6f4a47
commit 1034986fa1
79 changed files with 14465 additions and 0 deletions
@@ -0,0 +1,706 @@
{ config, pkgs, lib, ... }:
let
appName = "beiwe-backend";
defaultPort = 8080;
cfg = config.infrastructure.${appName};
# Build the beiwe-backend package
beiwePackage = if cfg.package != null then cfg.package else
pkgs.callPackage ./package.nix {
rev = cfg.version;
};
# Construct the Celery broker URL from RabbitMQ settings
celeryBrokerUrl = if cfg.celery.enable then
"amqp://${cfg.celery.rabbitmq.user}:${cfg.celery.rabbitmq.password}@${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}/${cfg.celery.rabbitmq.vhost}"
else "";
# Environment variables for beiwe-backend configuration
# See: https://github.com/jhsware/beiwe-backend (fork with env var support)
beiweEnvironment = {
# Required settings
DOMAIN_NAME = cfg.domainName;
FLASK_SECRET_KEY = cfg.flaskSecretKey;
SYSADMIN_EMAILS = cfg.sysadminEmails;
# Database settings (PostgreSQL)
RDS_DB_NAME = cfg.database.name;
RDS_USERNAME = cfg.database.user;
RDS_PASSWORD = cfg.database.password;
RDS_HOSTNAME = cfg.database.host;
RDS_PORT = toString cfg.database.port;
# PostgreSQL SSL mode
# Multiple env vars to ensure compatibility with different Django/psycopg versions
PGSSLMODE = cfg.database.sslmode;
DATABASE_SSLMODE = cfg.database.sslmode;
# S3/MinIO settings
S3_BUCKET = cfg.s3.bucket;
AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
BEIWE_SERVER_AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
BEIWE_SERVER_AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
S3_ACCESS_CREDENTIALS_USER = cfg.s3.accessKeyId;
S3_ACCESS_CREDENTIALS_KEY = cfg.s3.secretAccessKey;
# Django settings
DJANGO_SETTINGS_MODULE = "config.django_settings";
} // (lib.optionalAttrs (cfg.s3.endpoint != "") {
# Custom S3 endpoint for MinIO
S3_ENDPOINT_URL = cfg.s3.endpoint;
AWS_S3_ENDPOINT_URL = cfg.s3.endpoint;
}) // (lib.optionalAttrs (cfg.sentry.dsn != "") {
# Sentry error tracking (optional)
SENTRY_ELASTIC_BEANSTALK_DSN = cfg.sentry.dsn;
SENTRY_DATA_PROCESSING_DSN = cfg.sentry.dsn;
}) // (lib.optionalAttrs cfg.celery.enable {
# Celery/RabbitMQ settings
CELERY_BROKER_URL = celeryBrokerUrl;
BROKER_URL = celeryBrokerUrl;
# jhsware fork environment variables for Celery configuration
# These replace the manager_ip file requirement
CELERY_MANAGER_IP = "${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}";
CELERY_PASSWORD = cfg.celery.rabbitmq.password;
}) // cfg.extraEnvironment;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.beiwe-backend";
# ==========================================================================
# Package and Version Configuration
# ==========================================================================
version = lib.mkOption {
type = lib.types.str;
description = ''
Git commit hash of beiwe-backend to install.
Uses jhsware fork which adds environment variable support for Celery.
Supported versions are defined in package.nix.
See package.nix for instructions on adding new versions.
'';
default = "93be878"; # jhsware fork with CELERY_MANAGER_IP/CELERY_PASSWORD env var support
example = "main";
};
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
Custom beiwe-backend package to use. If null, the package will be built
using the version specified in 'version' option.
'';
default = null;
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind beiwe-backend to.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for beiwe-backend web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for beiwe-backend.";
default = false;
};
domainName = lib.mkOption {
type = lib.types.str;
description = "Domain name for the Beiwe backend (used in DOMAIN_NAME env var).";
default = "localhost:8080";
example = "beiwe.example.com";
};
# ==========================================================================
# Security Configuration
# ==========================================================================
flaskSecretKey = lib.mkOption {
type = lib.types.str;
description = ''
A unique, cryptographically secure string for Flask sessions.
IMPORTANT: Change this in production!
'';
default = "CHANGE_ME_IN_PRODUCTION_use_a_random_string";
example = "your-super-secret-random-key-here";
};
sysadminEmails = lib.mkOption {
type = lib.types.str;
description = "System administrator email addresses (comma-separated).";
default = "sysadmin@localhost";
example = "admin@example.com";
};
# ==========================================================================
# Data Directory
# ==========================================================================
dataDir = lib.mkOption {
type = lib.types.path;
description = "Directory where beiwe-backend data is stored.";
default = "/var/lib/beiwe-backend";
};
# ==========================================================================
# Database Configuration (PostgreSQL)
# ==========================================================================
database = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host.";
default = "/run/postgresql";
example = "localhost";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
name = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "beiwe";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "beiwe";
};
password = lib.mkOption {
type = lib.types.str;
description = ''
PostgreSQL password. Required by Beiwe even when using trust authentication.
For trust authentication, use an empty string or placeholder value.
'';
default = "unused_with_trust_auth";
example = "secure-password-here";
};
passwordSecretName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the secret containing the PostgreSQL password.
The secret should be placed at /run/secrets/<n>.
If null, peer/socket authentication is assumed.
'';
default = null;
example = "beiwe-db-password";
};
sslmode = lib.mkOption {
type = lib.types.enum [ "disable" "allow" "prefer" "require" "verify-ca" "verify-full" ];
description = ''
PostgreSQL SSL mode. For local development without SSL certificates,
use "disable". For production with SSL, use "require" or "verify-full".
See: https://www.postgresql.org/docs/current/libpq-ssl.html
'';
default = "prefer";
example = "disable";
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database user locally.
This requires PostgreSQL to be running locally with trust or peer authentication.
'';
default = false;
};
};
# ==========================================================================
# S3/MinIO Configuration
# ==========================================================================
s3 = {
bucket = lib.mkOption {
type = lib.types.str;
description = "S3 bucket name for data storage.";
default = "beiwe-data";
};
accessKeyId = lib.mkOption {
type = lib.types.str;
description = "AWS/MinIO access key ID.";
default = "";
example = "minioadmin";
};
secretAccessKey = lib.mkOption {
type = lib.types.str;
description = "AWS/MinIO secret access key.";
default = "";
example = "minioadmin";
};
endpoint = lib.mkOption {
type = lib.types.str;
description = ''
Custom S3 endpoint URL for MinIO or other S3-compatible storage.
Leave empty for AWS S3.
'';
default = "";
example = "http://localhost:9000";
};
};
# ==========================================================================
# Sentry Configuration (Optional)
# ==========================================================================
sentry = {
dsn = lib.mkOption {
type = lib.types.str;
description = "Sentry DSN for error tracking. Leave empty to disable.";
default = "";
example = "https://xxx@sentry.io/xxx";
};
};
# ==========================================================================
# Celery Configuration (Optional - for background tasks)
# ==========================================================================
celery = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Celery worker for background task processing.
When enabled, the following features become available:
- Push notifications to mobile apps
- Data processing pipelines
- Forest analysis integration
Requires RabbitMQ to be running and accessible.
'';
default = false;
};
rabbitmq = {
host = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ host for Celery broker.";
default = "127.0.0.1";
example = "rabbitmq.example.com";
};
port = lib.mkOption {
type = lib.types.int;
description = "RabbitMQ port.";
default = 5672;
};
user = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ user.";
default = "guest";
example = "beiwe";
};
password = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ password.";
default = "guest";
example = "secure-password";
};
vhost = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ virtual host.";
default = "";
example = "beiwe";
};
};
concurrency = lib.mkOption {
type = lib.types.int;
description = "Number of concurrent Celery worker processes.";
default = 2;
};
queues = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Celery queues to process. Beiwe uses separate queues for different tasks:
- celery (default queue)
- data_processing
- push_notifications
- forest
'';
default = [ "celery" "data_processing" "push_notifications" "forest" ];
};
logLevel = lib.mkOption {
type = lib.types.enum [ "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" ];
description = "Celery worker log level.";
default = "INFO";
};
};
# ==========================================================================
# Gunicorn Configuration
# ==========================================================================
gunicorn = {
workers = lib.mkOption {
type = lib.types.int;
description = "Number of Gunicorn worker processes.";
default = 4;
};
threads = lib.mkOption {
type = lib.types.int;
description = "Number of threads per worker.";
default = 2;
};
timeout = lib.mkOption {
type = lib.types.int;
description = "Request timeout in seconds.";
default = 120;
};
};
# ==========================================================================
# Extra Environment Variables
# ==========================================================================
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''
Additional environment variables for beiwe-backend.
These are passed directly to the service.
'';
default = {};
example = lib.literalExpression ''
{
DEBUG = "false";
}
'';
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for beiwe-backend.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "beiwe.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Beiwe User and Group
# ==========================================================================
users.users.beiwe = {
isSystemUser = true;
group = "beiwe";
home = cfg.dataDir;
createHome = true;
description = "Beiwe backend service user";
};
users.groups.beiwe = {};
# ==========================================================================
# Beiwe Backend Systemd Service (Web Server)
# ==========================================================================
systemd.services.beiwe-backend = {
description = "Beiwe Backend - Digital Phenotyping Research Platform";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" ] ++
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
lib.optionals cfg.celery.enable [ "rabbitmq.service" ];
wants = lib.optionals cfg.database.createLocally [
"beiwe-db-setup.service"
];
requires = lib.optionals cfg.database.createLocally [
"postgresql.service"
];
environment = beiweEnvironment;
# Load database password from secret file if specified
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
ExecStartPre = let
preStartScript = pkgs.writeShellScript "beiwe-pre-start" ''
# Run database migrations
${beiwePackage}/bin/beiwe-manage migrate --noinput || true
'';
in "+${preStartScript}";
ExecStart = ''
${beiwePackage}/bin/beiwe-gunicorn wsgi:application \
--bind ${cfg.bindToIp}:${toString cfg.bindToPort} \
--workers ${toString cfg.gunicorn.workers} \
--threads ${toString cfg.gunicorn.threads} \
--timeout ${toString cfg.gunicorn.timeout} \
--access-logfile - \
--error-logfile -
'';
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# Beiwe Celery Worker Service (Background Task Processing)
# ==========================================================================
#
# Uses jhsware fork which supports CELERY_MANAGER_IP and CELERY_PASSWORD
# environment variables instead of requiring a manager_ip file.
systemd.services.beiwe-celery-worker = lib.mkIf cfg.celery.enable {
description = "Beiwe Celery Worker - Background Task Processing";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" "rabbitmq.service" ];
requires = [ "rabbitmq.service" ];
wants = [ "beiwe-backend.service" ];
environment = beiweEnvironment;
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
# Celery command pattern from beiwe-backend wiki:
# python3 -m celery -A services.celery_data_processing worker -Q ...
ExecStart = let
queuesArg = lib.concatStringsSep "," cfg.celery.queues;
in ''
${beiwePackage}/bin/beiwe-celery \
-A services.celery_data_processing \
worker \
--queues=${queuesArg} \
--concurrency=${toString cfg.celery.concurrency} \
--loglevel=${cfg.celery.logLevel}
'';
Restart = "on-failure";
RestartSec = "10s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir "/tmp" ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# Beiwe Celery Beat Service (Scheduled Tasks)
# ==========================================================================
systemd.services.beiwe-celery-beat = lib.mkIf cfg.celery.enable {
description = "Beiwe Celery Beat - Task Scheduler";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "rabbitmq.service" "beiwe-celery-worker.service" ];
requires = [ "rabbitmq.service" ];
wants = [ "beiwe-celery-worker.service" ];
environment = beiweEnvironment;
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
ExecStart = ''
${beiwePackage}/bin/beiwe-celery \
-A services.celery_data_processing \
beat \
--loglevel=${cfg.celery.logLevel} \
--schedule=${cfg.dataDir}/celerybeat-schedule
'';
Restart = "on-failure";
RestartSec = "10s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# PostgreSQL Database Setup (Optional)
# ==========================================================================
systemd.services.beiwe-db-setup = lib.mkIf cfg.database.createLocally {
description = "Create Beiwe database and user";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
before = [ "beiwe-backend.service" ];
requiredBy = [ "beiwe-backend.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
};
script = let
dbUser = cfg.database.user;
dbName = cfg.database.name;
dbHost = cfg.database.host;
dbPort = toString cfg.database.port;
in ''
set -euo pipefail
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort} 2>/dev/null; do
sleep 1
done
echo "PostgreSQL is ready"
# Create database user if it doesn't exist
echo "Checking if user '${dbUser}' exists..."
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1; then
echo "Creating user '${dbUser}'..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
else
echo "User '${dbUser}' already exists"
fi
# Create database if it doesn't exist
echo "Checking if database '${dbName}' exists..."
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_database WHERE datname='${dbName}'" | grep -q 1; then
echo "Creating database '${dbName}'..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE DATABASE ${dbName} OWNER ${dbUser}"
else
echo "Database '${dbName}' already exists"
fi
# Grant privileges on database (idempotent)
echo "Granting privileges..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}" || true
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}" || true
echo "Database setup complete"
'';
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
locations."/" = {
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
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;
proxy_read_timeout ${toString cfg.gunicorn.timeout}s;
proxy_connect_timeout ${toString cfg.gunicorn.timeout}s;
client_max_body_size 100M;
'';
};
# Static files
locations."/static/" = {
alias = "${beiwePackage}/lib/beiwe-backend/frontend/static/";
extraConfig = ''
expires 30d;
add_header Cache-Control "public, immutable";
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = [
beiwePackage
pkgs.curl
pkgs.jq
];
};
}
@@ -0,0 +1,309 @@
# Beiwe Backend package
# A Django-based smartphone digital phenotyping research platform backend
#
# Using jhsware fork which adds environment variable support for Celery configuration
# (CELERY_MANAGER_IP and CELERY_PASSWORD instead of manager_ip file)
#
# To update to a new version:
# 1. Update the rev to the new commit hash
# 2. Run: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<NEW_COMMIT>.tar.gz
# 3. Update the hash with the output from step 2
{
lib,
stdenv,
fetchFromGitHub,
fetchPypi,
python312,
python312Packages,
postgresql,
# Custom parameters
rev ? "93be878", # jhsware fork with env var support for Celery
}:
let
# Known version hashes
# To add a new version, run:
# nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<COMMIT>.tar.gz
versionHashes = {
# jhsware fork with CELERY_MANAGER_IP and CELERY_PASSWORD env var support
"93be878" = {
srcHash = "sha256-marYxVINxgW0X9x+xoHL7bdRYEgm+4q+O1CF5WXeZGg=";
};
# Original onnela-lab version (for reference)
"6bb5363" = {
srcHash = "sha256-EeD+I3mWC81mhmlO9cKzRrArDLKVBmqhZjgJI8+geu0=";
};
};
hashes = versionHashes.${rev} or (throw ''
beiwe-backend revision ${rev} is not supported.
Supported revisions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
To add support for revision ${rev}:
1. Get source hash: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/${rev}.tar.gz
2. Add entry to versionHashes in app_modules/_unstable/beiwe-backend/package.nix
'');
# Build cronutils from PyPI (not available in nixpkgs)
cronutils = python312Packages.buildPythonPackage rec {
pname = "cronutils";
version = "0.4.2";
format = "setuptools";
src = fetchPypi {
inherit pname version;
hash = "sha256-SFHkQ9NltAyWArArkFpSBIJF3gMoXbxHEXreM1SEPUY=";
};
propagatedBuildInputs = with python312Packages; [
sentry-sdk
];
# Tests require network access
doCheck = false;
pythonImportsCheck = [ "cronutils" ];
meta = with lib; {
description = "Utilities for cron jobs including error handling";
homepage = "https://pypi.org/project/cronutils/";
license = licenses.mit;
};
};
# Build beiwe-forest from GitHub (Forest analysis library)
# See: https://github.com/onnela-lab/forest
# Note: pip install git+https://github.com/onnela-lab/forest
beiweForest = python312Packages.buildPythonPackage rec {
pname = "forest";
version = "unstable-2024-12-01";
format = "pyproject";
src = fetchFromGitHub {
owner = "onnela-lab";
repo = "forest";
rev = "develop"; # Main development branch
hash = "sha256-t+oq/jfJUmWCs0XzrN+xciYc3lz4oPiO8V8qfj3iTJA=";
};
nativeBuildInputs = with python312Packages; [
setuptools
];
propagatedBuildInputs = with python312Packages; [
# Core data science
numpy
pandas
scipy
scikit-learn
# Time/date utilities
pytz
holidays
timezonefinder
# GIS/mapping
shapely
pyproj
# Audio processing (for voice analysis)
librosa
# HTTP/API
requests
ratelimit
];
# Some optional dependencies not in nixpkgs (openrouteservice, ssqueezepy)
# Disable strict runtime deps check to allow partial functionality
pythonRelaxDeps = true;
pythonRemoveDeps = [ "openrouteservice" "ssqueezepy" ];
# Tests require data files
doCheck = false;
pythonImportsCheck = [ "forest" ];
meta = with lib; {
description = "Forest library for analyzing Beiwe digital phenotyping data";
homepage = "https://github.com/onnela-lab/forest";
license = licenses.bsd3;
};
};
# Python environment with all dependencies from requirements.txt
pythonEnv = python312.withPackages (ps: with ps; [
# Django and web framework
django
django-extensions
django-timezone-field # Provides timezone_field module
gunicorn
jinja2
# Database - using psycopg (v3) as specified in requirements.txt
psycopg
# AWS/S3 support
boto3
# Celery for task queue
celery
# Error tracking and monitoring
sentry-sdk
cronutils # Custom package built above
# Firebase (push notifications)
firebase-admin
# Security and crypto
pycryptodomex # Note: pycryptodomex not pycryptodome
pyotp
bleach
# Serialization
orjson
# Date/time utilities
python-dateutil
pytz
# Compression - pyzstd provides "import pyzstd" (jhsware fork uses pyzstd)
pyzstd
# Data analysis
numpy
pandas
scipy
scikit-learn
beiweForest # Custom package - provides "import forest"
# Other utilities
requests
rcssmin
pypng
pyqrcode
# Development/debugging
ipython
mypy
]);
in
stdenv.mkDerivation {
pname = "beiwe-backend";
version = rev;
src = fetchFromGitHub {
owner = "jhsware"; # Fork with env var support for Celery
repo = "beiwe-backend";
inherit rev;
hash = hashes.srcHash;
};
buildInputs = [
pythonEnv
postgresql
];
# No build phase needed - this is a Python application
dontBuild = true;
# Patch Django settings to support DATABASE_SSLMODE environment variable
# This allows controlling PostgreSQL SSL mode via environment variable
postPatch = ''
# Find the Django settings file and patch the database configuration
# to include sslmode from environment variable
# Add sslmode support to database configuration
# This sed command finds the DATABASES dict and adds OPTIONS with sslmode
if [ -f config/django_settings.py ]; then
echo "Patching config/django_settings.py for DATABASE_SSLMODE support..."
# Add import for os at the top if not already there
if ! grep -q "^import os" config/django_settings.py; then
sed -i '1i import os' config/django_settings.py
fi
# Append code to add sslmode to database options at the end of the file
cat >> config/django_settings.py << 'SSLPATCH'
# Patched by nix-infra-machine: Add DATABASE_SSLMODE support
# This allows setting PostgreSQL sslmode via environment variable
_db_sslmode = os.environ.get('DATABASE_SSLMODE', os.environ.get('PGSSLMODE', 'prefer'))
if 'default' in DATABASES:
if 'OPTIONS' not in DATABASES['default']:
DATABASES['default']['OPTIONS'] = {}
DATABASES['default']['OPTIONS']['sslmode'] = _db_sslmode
SSLPATCH
echo "Patched database settings for sslmode support"
else
echo "Warning: config/django_settings.py not found, skipping sslmode patch"
fi
'';
installPhase = ''
runHook preInstall
# Create directory structure
mkdir -p $out/lib/beiwe-backend
mkdir -p $out/bin
# Copy all source files
cp -r . $out/lib/beiwe-backend/
# Create wrapper scripts
cat > $out/bin/beiwe-manage <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/python manage.py "\$@"
EOF
chmod +x $out/bin/beiwe-manage
cat > $out/bin/beiwe-gunicorn <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/gunicorn "\$@"
EOF
chmod +x $out/bin/beiwe-gunicorn
cat > $out/bin/beiwe-celery <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/celery "\$@"
EOF
chmod +x $out/bin/beiwe-celery
runHook postInstall
'';
meta = {
description = "Beiwe - smartphone-based digital phenotyping research platform backend";
longDescription = ''
The Beiwe Research Platform collects high-throughput smartphone-based
digital phenotyping data including spatial trajectories (GPS), physical
activity patterns (accelerometer/gyroscope), social networks and
communication dynamics (call/text logs), and voice samples.
This package provides the Django-based backend server that supports:
- Web-based study management portal
- API endpoints for iOS/Android mobile apps
- Data processing pipelines
This is the jhsware fork which adds environment variable support for
Celery configuration (CELERY_MANAGER_IP and CELERY_PASSWORD).
Patched by nix-infra-machine to support DATABASE_SSLMODE environment
variable for controlling PostgreSQL SSL mode.
'';
homepage = "https://github.com/jhsware/beiwe-backend";
license = lib.licenses.bsd3;
platforms = lib.platforms.unix;
};
}
@@ -0,0 +1,318 @@
# CrowdSec Firewall Bouncer Module
# Provides firewall-level IP blocking using iptables/nftables/ipset
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
features.firewallBouncer = lib.mkOption {
type = lib.types.bool;
description = ''
Enable the firewall bouncer to automatically block malicious IPs.
The bouncer fetches decisions from the CrowdSec API and applies
them to the system firewall (iptables/nftables). Available in
nixpkgs as pkgs.crowdsec-firewall-bouncer starting from NixOS 25.11.
When using nftables mode (default), the module creates declarative
nftables tables that integrate properly with NixOS's firewall and
survive system rebuilds.
[NIS2 COMPLIANCE]
Article 21(2)(b) - Incident Handling: Provides automated incident
response by blocking identified threats in real-time.
Article 21(2)(d) - Network Security: Implements active network
protection through automated firewall rule management.
'';
default = false;
};
bouncer = {
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
CrowdSec firewall bouncer package to use.
The package is available in nixpkgs as pkgs.crowdsec-firewall-bouncer
starting from NixOS 25.11.
Set to null to disable the bouncer even when features.firewallBouncer
is enabled (useful for testing detection without blocking).
'';
default = pkgs.crowdsec-firewall-bouncer or null;
defaultText = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
example = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
};
mode = lib.mkOption {
type = lib.types.enum [ "iptables" "nftables" "ipset" ];
description = ''
Firewall mode for the bouncer.
- "nftables": Recommended for NixOS. Uses nftables sets which integrate
well with NixOS declarative firewall. The module creates the necessary
tables/chains declaratively, and the bouncer only manages set membership.
- "iptables": Traditional iptables rules. May conflict with NixOS firewall
on system rebuilds.
- "ipset": Uses ipset for IP blocking. More compatible with iptables-based
firewalls and survives rule flushes better.
[NIS2 COMPLIANCE]
All modes provide equivalent security protection. Choose based on your
existing firewall infrastructure.
'';
default = "nftables";
};
nftablesIntegration = lib.mkOption {
type = lib.types.bool;
description = ''
When using nftables mode, declaratively create the CrowdSec table
structure in NixOS configuration. This ensures the tables/chains
survive NixOS rebuilds and prevents conflicts with the declarative
firewall.
When enabled:
- Creates "crowdsec" and "crowdsec6" tables declaratively
- Configures bouncer in "set-only" mode
- Bouncer only manages IP set membership, not table structure
When disabled:
- Bouncer creates and manages its own tables
- May conflict with NixOS firewall rebuilds
'';
default = true;
};
denyAction = lib.mkOption {
type = lib.types.enum [ "DROP" "REJECT" ];
description = ''
Action to take for blocked IPs.
- "DROP": Silently drop packets (recommended for security)
- "REJECT": Send rejection response to client
DROP is generally preferred as it doesn't reveal firewall presence.
'';
default = "DROP";
};
denyLog = lib.mkOption {
type = lib.types.bool;
description = ''
Log blocked connections before dropping/rejecting.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Maintains audit trail
of blocked threats for incident analysis and reporting.
'';
default = true;
};
denyLogPrefix = lib.mkOption {
type = lib.types.str;
description = "Prefix for firewall log entries.";
default = "crowdsec: ";
};
banDuration = lib.mkOption {
type = lib.types.str;
description = ''
Default ban duration for blocked IPs.
Format: Go duration string (e.g., "4h", "24h", "7d")
'';
default = "4h";
example = "24h";
};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.features.firewallBouncer && cfg.bouncer.package != null) (
let
useNftablesIntegration = cfg.bouncer.mode == "nftables" && cfg.bouncer.nftablesIntegration;
# Bouncer config - uses set-only mode when nftablesIntegration is enabled
bouncerConfigFile = yamlFormat.generate "crowdsec-firewall-bouncer.yaml" ({
mode = cfg.bouncer.mode;
update_frequency = "10s";
api_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/";
api_key = "\${BOUNCER_API_KEY}";
disable_ipv6 = false;
deny_action = cfg.bouncer.denyAction;
deny_log = cfg.bouncer.denyLog;
deny_log_prefix = cfg.bouncer.denyLogPrefix;
} // lib.optionalAttrs (cfg.bouncer.mode == "nftables") {
nftables = {
ipv4 = {
enabled = true;
set-only = useNftablesIntegration;
table = "crowdsec";
chain = "crowdsec-chain";
set = "crowdsec-blocklist";
};
ipv6 = {
enabled = true;
set-only = useNftablesIntegration;
table = "crowdsec6";
chain = "crowdsec6-chain";
set = "crowdsec6-blocklist";
};
};
} // lib.optionalAttrs (cfg.bouncer.mode == "iptables") {
iptables_chains = [ "INPUT" "FORWARD" ];
} // lib.optionalAttrs (cfg.bouncer.mode == "ipset") {
ipset_type = "nethash";
ipset = "crowdsec-blocklist";
ipset6 = "crowdsec6-blocklist";
});
bouncerRegisterScript = pkgs.writeShellScript "crowdsec-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="/var/lib/crowdsec-firewall-bouncer/api_key"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "firewall-bouncer"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add firewall-bouncer -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
fi
# Read existing key
if [ -f "$KEY_FILE" ]; then
export BOUNCER_API_KEY=$(cat "$KEY_FILE")
fi
# Generate config with key substituted
# Use | as sed delimiter since API keys may contain /
if [ -n "$BOUNCER_API_KEY" ]; then
sed "s|\''${BOUNCER_API_KEY}|$BOUNCER_API_KEY|g" ${bouncerConfigFile} > /var/lib/crowdsec-firewall-bouncer/config.yaml
fi
'';
in lib.mkMerge [
# Assertions
{
assertions = [
{
assertion = cfg.bouncer.package != null;
message = ''
CrowdSec firewall bouncer is enabled but no package is configured.
The bouncer package should be available as pkgs.crowdsec-firewall-bouncer
on NixOS 25.11+. If using an older NixOS version, you may need to:
1. Upgrade to NixOS 25.11+
2. Set infrastructure.crowdsec.features.firewallBouncer = false
3. Provide the package from an external source
'';
}
];
# Install CLI tools based on mode
environment.systemPackages =
lib.optionals (cfg.bouncer.mode == "nftables") [ pkgs.nftables ]
++ lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables ]
++ lib.optionals (cfg.bouncer.mode == "ipset") [ pkgs.ipset ];
}
# Declarative nftables Integration
(lib.mkIf useNftablesIntegration {
networking.nftables.enable = true;
networking.nftables.tables = {
# IPv4 CrowdSec table
crowdsec = {
family = "ip";
content = ''
set crowdsec-blocklist {
type ipv4_addr
flags timeout
}
chain crowdsec-chain {
type filter hook input priority -1; policy accept;
${lib.optionalString cfg.bouncer.denyLog ''
ip saddr @crowdsec-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
''}
ip saddr @crowdsec-blocklist ${lib.toLower cfg.bouncer.denyAction}
}
'';
};
# IPv6 CrowdSec table
crowdsec6 = {
family = "ip6";
content = ''
set crowdsec6-blocklist {
type ipv6_addr
flags timeout
}
chain crowdsec6-chain {
type filter hook input priority -1; policy accept;
${lib.optionalString cfg.bouncer.denyLog ''
ip6 saddr @crowdsec6-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
''}
ip6 saddr @crowdsec6-blocklist ${lib.toLower cfg.bouncer.denyAction}
}
'';
};
};
})
# Tmpfiles and service
{
systemd.tmpfiles.rules = [
"d /var/lib/crowdsec-firewall-bouncer 0750 root root - -"
];
# Firewall bouncer service
systemd.services.crowdsec-firewall-bouncer = {
description = "CrowdSec Firewall Bouncer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
path = lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables pkgs.ipset ];
serviceConfig = {
Type = "simple";
ExecStartPre = "${bouncerRegisterScript}";
ExecStart = "${cfg.bouncer.package}/bin/cs-firewall-bouncer -c /var/lib/crowdsec-firewall-bouncer/config.yaml";
Restart = "always";
RestartSec = "10s";
};
};
}
]
);
}
@@ -0,0 +1,181 @@
# CrowdSec HAProxy SPOA Bouncer Module
# Provides application-layer protection via HAProxy Stream Processing Offload API
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
features.haproxyProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable HAProxy security integration via SPOA (Stream Processing Offload API).
The cs-haproxy-spoa-bouncer acts as a stream processing agent that checks
each connection in real-time against CrowdSec's decision database before
allowing traffic to reach your application servers.
This provides layer 7 application-level protection, complementing the
layer 3/4 protection from the firewall bouncer.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides application-layer protection
for HTTP/HTTPS traffic through HAProxy integration.
Article 21(2)(e) - Supply Chain Security: Protects web applications that
may be part of the digital supply chain.
'';
default = false;
};
haproxy = {
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
CrowdSec HAProxy SPOA bouncer package to use.
The package should be available as pkgs.cs-haproxy-spoa-bouncer.
Set to null to disable the bouncer even when features.haproxyProtection
is enabled.
'';
default = pkgs.cs-haproxy-spoa-bouncer or null;
defaultText = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
example = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
};
listenAddr = lib.mkOption {
type = lib.types.str;
description = ''
Address for the SPOA bouncer to listen on.
HAProxy will connect to this address to check decisions.
'';
default = "127.0.0.1";
example = "0.0.0.0";
};
listenPort = lib.mkOption {
type = lib.types.port;
description = "Port for the SPOA bouncer to listen on.";
default = 3000;
example = 3000;
};
action = lib.mkOption {
type = lib.types.enum [ "deny" "tarpit" ];
description = ''
Action to take for blocked requests in HAProxy.
- "deny": Immediately reject the connection
- "tarpit": Slow down the connection (tar pit)
'';
default = "deny";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "error" "warning" "info" "debug" ];
description = "Log level for the SPOA bouncer.";
default = "info";
};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.features.haproxyProtection && cfg.haproxy.package != null) (
let
# HAProxy SPOA bouncer config file
haproxyBouncerConfigFile = yamlFormat.generate "crowdsec-haproxy-spoa-bouncer.yaml" {
lapi_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
lapi_key = "\${HAPROXY_SPOA_API_KEY}";
action = cfg.haproxy.action;
log_level = cfg.haproxy.logLevel;
listen_addr = cfg.haproxy.listenAddr;
listen_port = cfg.haproxy.listenPort;
update_frequency = "10s";
};
haproxyBouncerRegisterScript = pkgs.writeShellScript "crowdsec-haproxy-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="/var/lib/crowdsec-haproxy-bouncer/api_key"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "haproxy-spoa-bouncer"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add haproxy-spoa-bouncer -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
fi
# Read existing key
if [ -f "$KEY_FILE" ]; then
export HAPROXY_SPOA_API_KEY=$(cat "$KEY_FILE")
fi
# Generate config with key substituted
# Use | as sed delimiter since API keys may contain /
if [ -n "$HAPROXY_SPOA_API_KEY" ]; then
sed "s|\''${HAPROXY_SPOA_API_KEY}|$HAPROXY_SPOA_API_KEY|g" ${haproxyBouncerConfigFile} > /var/lib/crowdsec-haproxy-bouncer/config.yaml
fi
'';
in {
# Assertions
assertions = [
{
assertion = cfg.haproxy.package != null;
message = ''
CrowdSec HAProxy SPOA bouncer is enabled but no package is configured.
The bouncer package should be available as pkgs.cs-haproxy-spoa-bouncer.
If the package is not available, you may need to:
1. Set infrastructure.crowdsec.features.haproxyProtection = false
2. Provide the package from an external source
'';
}
];
# Tmpfiles
systemd.tmpfiles.rules = [
"d /var/lib/crowdsec-haproxy-bouncer 0750 root root - -"
];
# HAProxy SPOA bouncer service
systemd.services.crowdsec-haproxy-bouncer = {
description = "CrowdSec HAProxy SPOA Bouncer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "simple";
ExecStartPre = "${haproxyBouncerRegisterScript}";
ExecStart = "${cfg.haproxy.package}/bin/cs-haproxy-spoa-bouncer -c /var/lib/crowdsec-haproxy-bouncer/config.yaml";
Restart = "always";
RestartSec = "10s";
};
};
}
);
}
@@ -0,0 +1,313 @@
# CrowdSec Python Bouncer Module
# Provides API key registration for pycrowdsec and python-capi-sdk integration
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.python = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Python bouncer registration for use with pycrowdsec.
This registers a bouncer with CrowdSec and stores the API key in a
configurable location so Python applications can use the pycrowdsec
library to check IPs against CrowdSec decisions.
Python applications should use the StreamClient or QueryClient from
pycrowdsec to query decisions:
```python
from pycrowdsec.client import StreamClient
client = StreamClient(
api_key=open("/run/crowdsec-python-bouncer/api_key").read().strip(),
lapi_url="http://127.0.0.1:8080/"
)
client.run()
action = client.get_action_for("1.2.3.4") # Returns "ban", "captcha", etc.
```
For Flask/Django integration, configure the middleware to read the API key
from the configured apiKeyFile path.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides application-layer protection
for Python web applications through CrowdSec integration.
'';
default = false;
};
bouncerName = lib.mkOption {
type = lib.types.str;
description = "Name to register the Python bouncer with in CrowdSec.";
default = "python-bouncer";
example = "my-flask-app-bouncer";
};
apiKeyFile = lib.mkOption {
type = lib.types.str;
description = ''
Path where the bouncer API key will be stored.
This file will be readable by the configured group (default: root).
Python applications need read access to this file to authenticate
with the CrowdSec Local API.
'';
default = "/run/crowdsec-python-bouncer/api_key";
example = "/run/secrets/crowdsec-python-api-key";
};
apiKeyFileGroup = lib.mkOption {
type = lib.types.str;
description = ''
Group that should have read access to the API key file.
Set this to match the group your Python application runs as.
For example, if your Flask app runs as user "flask" in group "flask",
set this to "flask".
'';
default = "root";
example = "www-data";
};
enableCapi = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Central API (CAPI) credentials for signal sharing with python-capi-sdk.
When enabled, this generates machine credentials that can be used with
the python-capi-sdk to send attack signals to CrowdSec's central infrastructure
and receive community blocklists.
The credentials are stored in the capiCredentialsFile location.
Example usage with python-capi-sdk:
```python
from cscapi.client import CAPIClient, CAPIClientConfig
from cscapi.sql_storage import SQLStorage
import yaml
# Load credentials generated by this module
with open("/run/crowdsec-python-bouncer/capi_credentials.yaml") as f:
creds = yaml.safe_load(f)
client = CAPIClient(
storage=SQLStorage(connection_string="sqlite:///signals.db"),
config=CAPIClientConfig(scenarios=["crowdsecurity/ssh-bf"])
)
```
[NIS2 COMPLIANCE]
Article 14 - Information Sharing: Enables participation in threat
intelligence sharing through the CrowdSec community network.
'';
default = false;
};
capiCredentialsFile = lib.mkOption {
type = lib.types.str;
description = ''
Path where CAPI credentials will be stored for python-capi-sdk.
This file contains machine_id and password for authenticating
with CrowdSec's Central API.
'';
default = "/run/crowdsec-python-bouncer/capi_credentials.yaml";
example = "/run/secrets/crowdsec-capi-credentials.yaml";
};
capiScenarios = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Scenarios that your Python application will report signals for.
These should match the attack patterns your application detects.
Common scenarios include:
- crowdsecurity/ssh-bf (SSH brute force)
- crowdsecurity/http-bf (HTTP brute force)
- crowdsecurity/http-crawl-non_statics (Web crawling)
'';
default = [];
example = [ "crowdsecurity/http-bf" "crowdsecurity/http-crawl-non_statics" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.python.enable) (
let
# Get the directory from the apiKeyFile path
pythonBouncerDir = builtins.dirOf cfg.python.apiKeyFile;
pythonCapiDir = builtins.dirOf cfg.python.capiCredentialsFile;
# Python bouncer registration script
pythonBouncerRegisterScript = pkgs.writeShellScript "crowdsec-python-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="${cfg.python.apiKeyFile}"
KEY_DIR="${pythonBouncerDir}"
BOUNCER_NAME="${cfg.python.bouncerName}"
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
# Create directory if it doesn't exist
mkdir -p "$KEY_DIR"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "$BOUNCER_NAME"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add "$BOUNCER_NAME" -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
# Set permissions: owner read/write, group read
chmod 640 "$KEY_FILE"
chown root:"$KEY_GROUP" "$KEY_FILE"
echo "Python bouncer '$BOUNCER_NAME' registered successfully"
echo "API key stored at: $KEY_FILE"
fi
else
echo "Python bouncer '$BOUNCER_NAME' already registered"
fi
# Create a helper config file for Python applications
LAPI_URL="http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
cat > "$KEY_DIR/config.yaml" << EOF
# CrowdSec Python Bouncer Configuration
# Generated by NixOS infrastructure.crowdsec module
#
# Usage with pycrowdsec:
# from pycrowdsec.client import StreamClient
# import yaml
#
# with open('${pythonBouncerDir}/config.yaml') as f:
# config = yaml.safe_load(f)
#
# client = StreamClient(
# api_key=open(config['api_key_file']).read().strip(),
# lapi_url=config['lapi_url']
# )
# client.run()
lapi_url: "$LAPI_URL"
api_key_file: "$KEY_FILE"
bouncer_name: "$BOUNCER_NAME"
EOF
chmod 644 "$KEY_DIR/config.yaml"
chown root:"$KEY_GROUP" "$KEY_DIR/config.yaml"
'';
# Python CAPI credentials script (for python-capi-sdk signal sharing)
pythonCapiRegisterScript = pkgs.writeShellScript "crowdsec-python-capi-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.openssl ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
CAPI_FILE="${cfg.python.capiCredentialsFile}"
CAPI_DIR="${pythonCapiDir}"
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
# Create directory if it doesn't exist
mkdir -p "$CAPI_DIR"
# Generate unique machine ID based on hostname and a random component
MACHINE_ID="python-$(hostname)-$(openssl rand -hex 4)"
# Generate a secure password
MACHINE_PASSWORD=$(openssl rand -base64 32)
# Check if credentials already exist
if [ -f "$CAPI_FILE" ]; then
echo "CAPI credentials already exist at $CAPI_FILE"
else
# Create credentials file for python-capi-sdk
cat > "$CAPI_FILE" << EOF
# CrowdSec Central API Credentials for python-capi-sdk
# Generated by NixOS infrastructure.crowdsec module
#
# Usage with python-capi-sdk:
# from cscapi.client import CAPIClient, CAPIClientConfig
# from cscapi.sql_storage import SQLStorage
# from cscapi.utils import generate_machine_id_from_key
# import yaml
#
# with open('${cfg.python.capiCredentialsFile}') as f:
# creds = yaml.safe_load(f)
#
# client = CAPIClient(
# storage=SQLStorage(connection_string="sqlite:///signals.db"),
# config=CAPIClientConfig(
# scenarios=${builtins.toJSON cfg.python.capiScenarios}
# )
# )
#
# # Note: Machine enrollment with CrowdSec CAPI requires manual approval
# # Contact CrowdSec for signal sharing partnership details
machine_id: "$MACHINE_ID"
password: "$MACHINE_PASSWORD"
scenarios: ${builtins.toJSON cfg.python.capiScenarios}
capi_url: "https://api.crowdsec.net/"
# Local API connection (for reading decisions)
lapi_url: "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
EOF
chmod 640 "$CAPI_FILE"
chown root:"$KEY_GROUP" "$CAPI_FILE"
echo "CAPI credentials generated at $CAPI_FILE"
echo ""
echo "NOTE: To share signals with CrowdSec CAPI, you need to:"
echo "1. Contact CrowdSec for signal sharing partnership enrollment"
echo "2. Use the machine_id from this file when enrolling"
echo "3. Update your application to use the python-capi-sdk"
fi
'';
in {
# Python bouncer registration service (oneshot - registers bouncer and stores API key)
systemd.services.crowdsec-python-bouncer = {
description = "CrowdSec Python Bouncer Registration";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pythonBouncerRegisterScript}";
};
};
# Python CAPI credentials service (oneshot - generates CAPI credentials for signal sharing)
systemd.services.crowdsec-python-capi = lib.mkIf cfg.python.enableCapi {
description = "CrowdSec Python CAPI Credentials Generation";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" "crowdsec-python-bouncer.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pythonCapiRegisterScript}";
};
};
}
);
}
@@ -0,0 +1,730 @@
# CrowdSec - Collaborative Intrusion Prevention System
#
# This module provides simplified boolean feature toggles for common use cases
# and can use either a custom implementation or the native NixOS module.
#
# Module structure:
# - default.nix: Core CrowdSec engine and detection features
# - bouncers/: Response modules (firewall, haproxy, python)
# - integrations/: External system integrations (auditd, console)
{ config, pkgs, lib, options, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
# ==========================================================================
# Version Detection (must not depend on cfg to avoid recursion)
# ==========================================================================
# Check if the native services.crowdsec module exists (NixOS 25.11+)
hasNativeCrowdsecModule = options ? services && options.services ? crowdsec;
# The native module in NixOS 25.11 has multiple bugs that make it unusable:
# - #445342: Missing sensible defaults, API server disabled by default
# - #446764: Console enrollment broken
# - #459224: Cannot enable local API
# - Missing hub.postoverflows, hub.scenarios, hub.parsers options
# - Null coercion errors in systemd service generation
#
# We mark the native module as unstable until these are fixed.
# Users can override with implementation = "native" to test.
nativeModuleIsStable = false;
# State directory for CrowdSec
stateDir = "/var/lib/crowdsec";
# Helper to generate YAML format
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Import Sub-Modules
# ==========================================================================
imports = [
# Bouncers - Response mechanisms
./bouncers/firewall.nix
./bouncers/haproxy.nix
./bouncers/python.nix
# Integrations - External system connections
./integrations/auditd.nix
./integrations/console.nix
];
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
enable = lib.mkEnableOption ''
CrowdSec - Collaborative Intrusion Prevention System.
CrowdSec is an open-source security automation tool that detects and blocks
malicious behavior by analyzing logs and sharing threat intelligence with
the community.
This module provides simplified boolean feature toggles for common use cases
and can use either a custom implementation or the native NixOS module.
[NIS2 COMPLIANCE]
Article 21(2)(b) - Incident Handling: CrowdSec provides automated threat
detection and response capabilities, helping organizations meet requirements
for detecting, analyzing, and responding to cybersecurity incidents.
Article 21(2)(d) - Network Security: Acts as an Intrusion Detection/Prevention
System (IDS/IPS), a core requirement for protecting network infrastructure.
'';
implementation = lib.mkOption {
type = lib.types.enum [ "auto" "native" "custom" ];
description = ''
Which implementation to use for CrowdSec.
- "auto": Automatically select based on NixOS version and module stability.
Currently defaults to "custom" because the native module has bugs.
- "native": Force use of NixOS's native services.crowdsec module.
Requires NixOS 25.11+. May have bugs - use for testing only.
- "custom": Use the custom implementation that manages its own systemd
service. Works on all NixOS versions with the crowdsec package.
The native module in NixOS 25.11 has several known issues:
- #445342: Missing sensible defaults
- #446764: Console enrollment broken
- #459224: Cannot enable local API
When these are fixed, "auto" will switch to using the native module.
'';
default = "auto";
example = "custom";
};
package = lib.mkOption {
type = lib.types.package;
description = "CrowdSec package to use.";
default = pkgs.crowdsec;
defaultText = lib.literalExpression "pkgs.crowdsec";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "trace" "debug" "info" "warning" "error" "fatal" ];
description = ''
Log level for CrowdSec.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Appropriate logging level
enables proper security event monitoring and incident investigation.
'';
default = "info";
example = "debug";
};
# ==========================================================================
# API Configuration
# ==========================================================================
api = {
listenAddr = lib.mkOption {
type = lib.types.str;
description = ''
Address for the CrowdSec Local API (LAPI) to listen on.
Use "127.0.0.1" for local-only access or "0.0.0.0" for network access.
'';
default = "127.0.0.1";
example = "0.0.0.0";
};
listenPort = lib.mkOption {
type = lib.types.port;
description = "Port for the CrowdSec Local API (LAPI) to listen on.";
default = 8080;
example = 8080;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to open the firewall port for the CrowdSec API.
Only needed if bouncers from other machines need to connect.
'';
default = false;
};
};
# ==========================================================================
# Detection Features (Simple Boolean Options)
# ==========================================================================
features = {
sshProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable SSH brute-force detection and prevention.
Monitors SSH authentication logs to detect and block IP addresses
attempting password guessing or credential stuffing attacks.
[NIS2 COMPLIANCE]
Article 21(2)(i) - Human Resources Security: Protects authentication
systems and helps prevent unauthorized access attempts.
Article 21(2)(j) - Access Control: Provides automated protection
against credential-based attacks on administrative interfaces.
'';
default = true;
};
nginxProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable nginx/web server attack detection.
Monitors nginx access and error logs to detect web-based attacks
including SQL injection, XSS, path traversal, and more.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides web application
firewall (WAF) capabilities to protect public-facing services.
Article 21(2)(e) - Supply Chain Security: Helps protect web
services that may be part of the digital supply chain.
'';
default = false;
};
nginxLogPaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Paths to nginx log files to monitor.";
default = [ "/var/log/nginx/*.log" ];
example = [ "/var/log/nginx/access.log" "/var/log/nginx/error.log" ];
};
systemProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable system/kernel-level threat detection.
Monitors kernel and system logs for suspicious activity including
privilege escalation attempts and system abuse.
[NIS2 COMPLIANCE]
Article 21(2)(a) - Risk Analysis: Provides continuous monitoring
to identify and respond to system-level threats.
Article 21(2)(g) - Security Monitoring: Implements comprehensive
security monitoring across the system infrastructure.
'';
default = false;
};
communityBlocklists = lib.mkOption {
type = lib.types.bool;
description = ''
Enable community-contributed IP blocklists.
When enrolled in the CrowdSec Console, your instance can receive
curated blocklists of known malicious IPs from the community.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Leverages collective threat
intelligence to proactively block known attackers.
Article 14 - Information Sharing: Participates in cybersecurity
information sharing to improve collective defense.
'';
default = true;
};
};
# ==========================================================================
# Hub Configuration (Parsers, Scenarios, Collections)
# ==========================================================================
hub = {
collections = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub collections to install.
Collections bundle related parsers and scenarios together.
Browse available collections at: https://hub.crowdsec.net/
'';
default = [];
example = [ "crowdsecurity/apache2" "crowdsecurity/postfix" ];
};
scenarios = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub scenarios to install.
Scenarios define detection rules for specific attack patterns.
'';
default = [];
example = [ "crowdsecurity/http-bf-wordpress_bf" ];
};
parsers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub parsers to install.
Parsers extract structured data from log files.
'';
default = [];
example = [ "crowdsecurity/docker-logs" ];
};
postoverflows = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional post-overflow parsers to install.";
default = [];
example = [ "crowdsecurity/cdn-whitelist" ];
};
};
# ==========================================================================
# Custom Acquisitions
# ==========================================================================
acquisitions = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
description = ''
Additional log sources for CrowdSec to monitor.
Each acquisition defines a log source (file, journalctl, etc.)
and the parser type to use.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Enables comprehensive
log collection and monitoring across all systems.
'';
default = [];
example = lib.literalExpression ''
[
{
source = "journalctl";
journalctl_filter = [ "_SYSTEMD_UNIT=postgresql.service" ];
labels.type = "syslog";
}
{
filenames = [ "/var/log/myapp/*.log" ];
labels.type = "syslog";
}
]
'';
};
# ==========================================================================
# Pass-through Configuration
# ==========================================================================
extraSettings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra settings merged into the CrowdSec configuration.
For native implementation: merged into services.crowdsec.settings.
For custom implementation: merged into the generated config.yaml.
'';
default = {};
};
extraLocalConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra settings merged into the local configuration.
For native implementation: merged into services.crowdsec.localConfig.
For custom implementation: not currently used.
'';
default = {};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf cfg.enable (
let
# ========================================================================
# All cfg-dependent values MUST be defined inside this let block
# to avoid infinite recursion during module evaluation
# ========================================================================
# Determine which implementation to use
useNativeImplementation =
if cfg.implementation == "native" then true
else if cfg.implementation == "custom" then false
else if cfg.implementation == "auto" then
hasNativeCrowdsecModule && nativeModuleIsStable
else false;
# Build acquisitions list based on enabled features
acquisitions = lib.flatten [
# SSH acquisition (journalctl-based)
(lib.optional cfg.features.sshProtection {
source = "journalctl";
journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ];
labels.type = "syslog";
})
# Nginx acquisition (log file-based)
(lib.optional cfg.features.nginxProtection {
filenames = cfg.features.nginxLogPaths;
labels.type = "nginx";
})
# System/kernel logs acquisition
(lib.optional cfg.features.systemProtection {
source = "journalctl";
journalctl_filter = [ "_TRANSPORT=kernel" ];
labels.type = "syslog";
})
# Custom acquisitions from user
cfg.acquisitions
];
# Build hub collections list based on enabled features
hubCollections = lib.flatten [
(lib.optional cfg.features.sshProtection "crowdsecurity/sshd")
(lib.optional cfg.features.nginxProtection "crowdsecurity/nginx")
(lib.optional cfg.features.systemProtection "crowdsecurity/linux")
cfg.hub.collections
];
# ========================================================================
# Custom Implementation: Configuration Files
# ========================================================================
# Generate acquisitions file as multi-document YAML
# CrowdSec expects each acquisition as a separate YAML document (separated by ---)
# We use yamlFormat.generate for each acquisition and concatenate them
acquisitionsFile = pkgs.writeText "acquisitions.yaml" (
lib.concatMapStringsSep "\n---\n" (acq:
builtins.readFile (yamlFormat.generate "acq.yaml" acq)
) acquisitions
);
# Generate simulation file (CrowdSec requires this)
simulationFile = yamlFormat.generate "simulation.yaml" {
simulation = false;
exclusions = [];
};
# Generate main config file (compatible with CrowdSec 1.7.x)
configFile = yamlFormat.generate "config.yaml" {
common = {
daemonize = false;
log_media = "stdout";
log_level = cfg.logLevel;
};
config_paths = {
config_dir = "${stateDir}/config";
data_dir = "${stateDir}/data";
hub_dir = "${stateDir}/hub";
simulation_path = "${stateDir}/config/simulation.yaml";
};
crowdsec_service = {
acquisition_path = "${stateDir}/config/acquisitions.yaml";
parser_routines = 1;
};
cscli = {
output = "human";
};
api = {
client = {
insecure_skip_verify = false;
credentials_path = "${stateDir}/config/local_api_credentials.yaml";
};
server = {
enable = true;
listen_uri = "${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
profiles_path = "${stateDir}/config/profiles.yaml";
online_client = {
credentials_path = "${stateDir}/config/online_api_credentials.yaml";
};
};
};
db_config = {
type = "sqlite";
db_path = "${stateDir}/data/crowdsec.db";
use_wal = true;
};
};
# Generate profiles file (CrowdSec expects multi-document YAML format)
# Use bouncer.banDuration if available, otherwise default to 4h
banDuration = cfg.bouncer.banDuration or "4h";
profilesFile = pkgs.writeText "profiles.yaml" ''
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: ${banDuration}
on_success: break
'';
# Initialization script - sets up CrowdSec on first run
initScript = pkgs.writeShellScript "crowdsec-init" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.nettools pkgs.findutils ]}:$PATH"
STATE_DIR="${stateDir}"
CONFIG_DIR="$STATE_DIR/config"
DATA_DIR="$STATE_DIR/data"
HUB_DIR="$STATE_DIR/hub"
PACKAGE="${cfg.package}"
# Create directories
mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$HUB_DIR"
# Copy configuration files
cp -f ${configFile} "$CONFIG_DIR/config.yaml"
cp -f ${profilesFile} "$CONFIG_DIR/profiles.yaml"
cp -f ${acquisitionsFile} "$CONFIG_DIR/acquisitions.yaml"
cp -f ${simulationFile} "$CONFIG_DIR/simulation.yaml"
# Debug: Show acquisitions file content
echo "Generated acquisitions.yaml:"
cat "$CONFIG_DIR/acquisitions.yaml"
echo ""
# Copy patterns directory from package (required for parser grok patterns)
echo "Looking for patterns directory..."
# Try common locations
PATTERNS_FOUND=0
for PATTERNS_PATH in \
"$PACKAGE/share/crowdsec/config/patterns" \
"$PACKAGE/share/crowdsec/patterns" \
"$PACKAGE/etc/crowdsec/patterns" \
; do
if [ -d "$PATTERNS_PATH" ]; then
echo "Found patterns at: $PATTERNS_PATH"
rm -rf "$CONFIG_DIR/patterns"
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
PATTERNS_FOUND=1
break
fi
done
# If not found in common locations, search the entire package
if [ "$PATTERNS_FOUND" = "0" ]; then
echo "Searching for patterns directory in package..."
PATTERNS_PATH=$(find "$PACKAGE" -type d -name "patterns" 2>/dev/null | head -1)
if [ -n "$PATTERNS_PATH" ]; then
echo "Found patterns at: $PATTERNS_PATH"
rm -rf "$CONFIG_DIR/patterns"
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
PATTERNS_FOUND=1
fi
fi
if [ "$PATTERNS_FOUND" = "0" ]; then
echo "WARNING: Could not find patterns directory!"
echo "Package contents:"
ls -la "$PACKAGE/" || true
ls -la "$PACKAGE/share/" || true
ls -la "$PACKAGE/share/crowdsec/" 2>/dev/null || true
fi
# Initialize database if it doesn't exist
if [ ! -f "$DATA_DIR/crowdsec.db" ]; then
echo "Initializing CrowdSec database..."
touch "$CONFIG_DIR/local_api_credentials.yaml"
touch "$CONFIG_DIR/online_api_credentials.yaml"
chmod 640 "$CONFIG_DIR/local_api_credentials.yaml"
chmod 640 "$CONFIG_DIR/online_api_credentials.yaml"
fi
# Generate machine ID if it doesn't exist
if [ ! -f "$CONFIG_DIR/local_api_credentials.yaml" ] || [ ! -s "$CONFIG_DIR/local_api_credentials.yaml" ]; then
echo "Registering local machine..."
cscli -c "$CONFIG_DIR/config.yaml" machines add "$(hostname)" --auto --force || true
fi
# Update hub index
echo "Updating hub index..."
cscli -c "$CONFIG_DIR/config.yaml" hub update || true
# Set correct ownership
chown -R crowdsec:crowdsec "$STATE_DIR"
'';
# Hub installation script (runs after service is started)
hubInstallScript = pkgs.writeShellScript "crowdsec-hub-install" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
# Wait for API to be ready
for i in $(seq 1 30); do
if cscli -c "$CONFIG_DIR/config.yaml" hub list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Install collections
${lib.concatMapStringsSep "\n" (c: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" collections list 2>/dev/null | grep -q "${c}"; then
cscli -c "$CONFIG_DIR/config.yaml" collections install ${c} || true
fi
'') hubCollections}
# Install additional scenarios
${lib.concatMapStringsSep "\n" (s: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" scenarios list 2>/dev/null | grep -q "${s}"; then
cscli -c "$CONFIG_DIR/config.yaml" scenarios install ${s} || true
fi
'') cfg.hub.scenarios}
# Install additional parsers
${lib.concatMapStringsSep "\n" (p: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" parsers list 2>/dev/null | grep -q "${p}"; then
cscli -c "$CONFIG_DIR/config.yaml" parsers install ${p} || true
fi
'') cfg.hub.parsers}
'';
in lib.mkMerge [
# ==========================================================================
# Common Configuration (both implementations)
# ==========================================================================
{
# Assertions
assertions = [
{
assertion = acquisitions != [];
message = ''
CrowdSec requires at least one acquisition source.
Enable at least one of:
- infrastructure.crowdsec.features.sshProtection = true
- infrastructure.crowdsec.features.nginxProtection = true
- infrastructure.crowdsec.features.systemProtection = true
Or add custom acquisitions via infrastructure.crowdsec.acquisitions
'';
}
{
assertion = cfg.implementation != "native" || hasNativeCrowdsecModule;
message = ''
CrowdSec native implementation requires NixOS 25.11 or later.
Either:
1. Upgrade to NixOS 25.11+
2. Set infrastructure.crowdsec.implementation = "custom"
3. Set infrastructure.crowdsec.implementation = "auto" (recommended)
'';
}
];
# Open firewall for LAPI if configured
networking.firewall.allowedTCPPorts =
lib.mkIf cfg.api.openFirewall [ cfg.api.listenPort ];
# Install useful CLI tools
environment.systemPackages = [
cfg.package # Includes cscli
];
}
# ==========================================================================
# Custom Implementation
# ==========================================================================
(lib.mkIf (!useNativeImplementation) {
# Create crowdsec user and group
users.users.crowdsec = {
isSystemUser = true;
group = "crowdsec";
home = stateDir;
description = "CrowdSec daemon user";
};
users.groups.crowdsec = {};
# Ensure data directories exist and create config symlink for cscli
systemd.tmpfiles.rules = [
"d ${stateDir} 0755 crowdsec crowdsec - -"
"d ${stateDir}/config 0755 crowdsec crowdsec - -"
"d ${stateDir}/data 0755 crowdsec crowdsec - -"
"d ${stateDir}/hub 0755 crowdsec crowdsec - -"
# Create /etc/crowdsec directory and symlink for cscli default config path
"L+ /etc/crowdsec/config.yaml - - - - ${stateDir}/config/config.yaml"
];
# Main CrowdSec service
systemd.services.crowdsec = {
description = "CrowdSec Security Engine";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "local-fs.target" ];
serviceConfig = {
Type = "simple";
User = "crowdsec";
Group = "crowdsec";
ExecStartPre = [
"+${initScript}" # Run as root for permissions
];
ExecStart = "${cfg.package}/bin/crowdsec -c ${stateDir}/config/config.yaml";
ExecStartPost = "${hubInstallScript}";
Restart = "always";
RestartSec = "10s";
# Security hardening
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [ stateDir ];
# Allow journal access for systemd log sources
SupplementaryGroups = lib.optional (cfg.features.sshProtection || cfg.features.systemProtection) "systemd-journal";
};
};
})
# ==========================================================================
# Native Implementation (NixOS 25.11+)
# ==========================================================================
(lib.mkIf (useNativeImplementation && hasNativeCrowdsecModule) {
# Workarounds for native module bugs
systemd.tmpfiles.rules = [
# WORKAROUND #445342: Create state directory
# WORKAROUND #446764: Create online_api_credentials.yaml
"f /var/lib/crowdsec/online_api_credentials.yaml 0640 crowdsec crowdsec - -"
];
services.crowdsec = {
enable = true;
package = cfg.package;
# Hub items to install (only collections - other options may not exist)
hub = {
collections = hubCollections;
};
# Local configuration (acquisitions)
localConfig = {
inherit acquisitions;
} // cfg.extraLocalConfig;
# Main settings
settings = lib.mkMerge [
{
# WORKAROUND: BUG #445342 - Enable API server by default
general.api.server.enable = true;
}
# Console enrollment (if configured)
# Note: console options are defined in integrations/console.nix
(lib.mkIf (cfg.console.enrollKeyFile != null) {
console.tokenFile = cfg.console.enrollKeyFile;
})
# User's extra settings
cfg.extraSettings
];
};
})
]
);
}
@@ -0,0 +1,87 @@
# CrowdSec Auditd Integration Module
# Provides kernel-level security event monitoring via Linux Audit Framework
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.auditd = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable auditd integration with CrowdSec.
When enabled, configures auditd to send audit events to CrowdSec
for analysis. This enables detection of:
- Privilege escalation attempts
- Unauthorized file access
- System call anomalies
- User authentication events
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Provides kernel-level
visibility into security events and potential threats.
'';
default = false;
};
rules = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional auditd rules to configure for CrowdSec monitoring.
These rules are added to the system's auditd configuration.
Common rules for security monitoring:
- File integrity: "-w /etc/passwd -p wa -k identity"
- Privilege escalation: "-w /usr/bin/sudo -p x -k privilege"
- Network configuration: "-w /etc/hosts -p wa -k network"
'';
default = [];
example = [
"-w /etc/passwd -p wa -k identity"
"-w /etc/shadow -p wa -k identity"
"-w /etc/sudoers -p wa -k privilege"
];
};
nixWrappersWhitelistProcess = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of process names to whitelist from auditd monitoring.
NOTE: This feature is currently disabled due to compatibility issues
with the 'comm' field filter in some versions of auditd. The option
is preserved for future use when auditd compatibility is resolved.
NixOS uses wrapper scripts in /run/wrappers/bin for setuid/setgid
programs (like sudo, ping, etc.). These wrappers can generate a lot
of noise in auditd logs.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Reduces audit log noise
while maintaining security visibility on critical processes.
'';
default = [];
example = [ "sshd" "systemd" "sudo" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.auditd.enable) {
# Enable the Linux Audit daemon
security.auditd.enable = true;
# Add user-defined audit rules
# Note: The nixWrappersWhitelistProcess feature is currently disabled
# due to auditd compatibility issues with the 'comm' field filter
security.audit.rules = cfg.auditd.rules;
};
}
@@ -0,0 +1,97 @@
# CrowdSec Console Integration Module
# Provides cloud enrollment and community threat intelligence sharing
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.console = {
enrollKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Path to file containing the CrowdSec Console enrollment key.
Enrolling connects your instance to the CrowdSec Console for:
- Centralized monitoring and management
- Access to community and commercial blocklists
- Threat intelligence dashboards
- Alert visualization and analytics
Get your enrollment key from: https://app.crowdsec.net/
The enrollment key should be stored securely, for example using
agenix or sops-nix for secrets management.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Provides centralized
visibility into security events across infrastructure.
Article 23 - Reporting: Facilitates incident documentation
and reporting through centralized logging.
'';
default = null;
example = "/run/secrets/crowdsec-enroll-key";
};
shareDecisions = lib.mkOption {
type = lib.types.bool;
description = ''
Share your detected threats with the CrowdSec community.
When enabled, anonymized attack signals are shared to improve
collective threat intelligence for all CrowdSec users. This is
a key part of CrowdSec's collaborative security model.
Shared data includes:
- Source IP addresses of attacks
- Attack type/scenario that triggered
- Timestamp of the attack
Personal data and log contents are NOT shared.
[NIS2 COMPLIANCE]
Article 14 - Information Sharing: Contributes to EU-wide
cybersecurity by participating in threat intelligence sharing.
'';
default = true;
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Custom name for this instance in the CrowdSec Console.
If not set, the hostname will be used. Useful for identifying
machines in multi-server deployments.
'';
default = null;
example = "web-server-01";
};
tags = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Tags to apply to this instance in the CrowdSec Console.
Tags help organize and filter machines in the console dashboard.
'';
default = [];
example = [ "production" "web-tier" "eu-west" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
# Note: The actual console enrollment is handled by the main module
# since it requires integration with both native and custom implementations.
# This module only defines the options.
#
# For native implementation: settings are passed to services.crowdsec.settings
# For custom implementation: enrollment is done via cscli in the init script
}
@@ -0,0 +1,423 @@
{ config, pkgs, lib, ... }:
let
appName = "n8n";
defaultPort = 5678;
cfg = config.infrastructure.${appName};
# Build the custom n8n package with version selection
n8nPackage = if cfg.package != null then cfg.package else
pkgs.callPackage ./package.nix {
version = cfg.version;
buildMemoryMB = cfg.buildMemoryMB;
};
# Environment variables for n8n configuration
n8nEnvironment = {
# Network settings
N8N_PORT = toString cfg.bindToPort;
N8N_LISTEN_ADDRESS = cfg.bindToIp;
# Execution settings
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
# Webhook URL (if specified)
WEBHOOK_URL = cfg.webhookUrl;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
# Database settings (only set if using PostgreSQL)
DB_TYPE = "postgresdb";
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
DB_POSTGRESDB_SSL_ENABLED = "true";
}) // cfg.settings;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.n8n";
# ==========================================================================
# Package and Version Configuration
# ==========================================================================
version = lib.mkOption {
type = lib.types.str;
description = ''
n8n version to install.
Supported versions are defined in package.nix. To add a new version,
you need to compute the source and pnpm dependency hashes.
See package.nix for instructions on adding new versions.
'';
default = "2.1.5";
example = "1.120.4";
};
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
Custom n8n package to use. If null, the package will be built
using the version specified in 'version' option.
Use this to provide a completely custom n8n build.
'';
default = null;
example = lib.literalExpression "pkgs.n8n";
};
buildMemoryMB = lib.mkOption {
type = lib.types.int;
description = ''
Maximum Node.js heap size in MB for building n8n.
Increase this if you encounter "JavaScript heap out of memory" errors during build.
'';
default = 4096;
example = 8192;
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind n8n to.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for n8n web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for n8n.";
default = false;
};
# ==========================================================================
# Webhook Configuration
# ==========================================================================
webhookUrl = lib.mkOption {
type = lib.types.str;
description = ''
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
This is the external URL where webhooks can reach n8n.
'';
default = "";
example = "https://n8n.example.com/";
};
# ==========================================================================
# Data Directory
# ==========================================================================
dataDir = lib.mkOption {
type = lib.types.path;
description = "Directory where n8n data is stored.";
default = "/var/lib/n8n";
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "postgresdb" ];
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
default = "sqlite";
};
postgresdb = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host.";
default = "localhost";
example = "/run/postgresql";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
database = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "n8n";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "n8n";
};
passwordSecretName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the secret containing the PostgreSQL password.
The secret should be placed at /run/secrets/<n>.
If null, peer/socket authentication is assumed.
'';
default = null;
example = "n8n-db-password";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL for PostgreSQL connection.";
default = false;
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database user locally.
This requires PostgreSQL to be running locally with trust or peer authentication.
The database itself should be created via infrastructure.postgresql.initialDatabases.
'';
default = false;
};
};
};
# ==========================================================================
# Execution Configuration
# ==========================================================================
executions = {
pruneData = lib.mkOption {
type = lib.types.bool;
description = "Enable automatic pruning of old execution data.";
default = true;
};
pruneDataMaxAge = lib.mkOption {
type = lib.types.int;
description = "Maximum age of execution data in hours before pruning.";
default = 336; # 14 days
};
pruneDataMaxCount = lib.mkOption {
type = lib.types.int;
description = "Maximum number of executions to keep.";
default = 10000;
};
};
# ==========================================================================
# n8n Settings (pass-through as environment variables)
# ==========================================================================
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional n8n configuration as environment variables.
These are passed directly to the n8n service.
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
'';
default = {};
example = lib.literalExpression ''
{
GENERIC_TIMEZONE = "Europe/London";
WORKFLOWS_DEFAULT_NAME = "My Workflow";
N8N_METRICS = "true";
}
'';
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for n8n.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "n8n.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Disable the native n8n service (we'll configure our own systemd service)
# ==========================================================================
# Do NOT enable services.n8n - we create our own service to have full control
# ==========================================================================
# n8n User and Group
# ==========================================================================
users.users.n8n = {
isSystemUser = true;
group = "n8n";
home = cfg.dataDir;
createHome = true;
description = "n8n service user";
};
users.groups.n8n = {};
# ==========================================================================
# n8n Systemd Service
# ==========================================================================
systemd.services.n8n = {
description = "n8n - Workflow Automation";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ] ++
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"postgresql.service"
"n8n-db-setup.service"
];
wants = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"n8n-db-setup.service"
];
requires = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"postgresql.service"
];
environment = n8nEnvironment;
serviceConfig = {
Type = "simple";
User = "n8n";
Group = "n8n";
WorkingDirectory = cfg.dataDir;
ExecStart = "${n8nPackage}/bin/n8n";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
locations."/" = {
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
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;
proxy_buffering off;
chunked_transfer_encoding off;
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Service Dependencies
# ==========================================================================
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
wants = [ "n8n.service" ];
};
# ==========================================================================
# PostgreSQL Database Setup (Optional)
# ==========================================================================
systemd.services.n8n-db-setup = lib.mkIf (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) {
description = "Create n8n database user";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
before = [ "n8n.service" ];
requiredBy = [ "n8n.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
};
script = let
dbUser = cfg.database.postgresdb.user;
dbName = cfg.database.postgresdb.database;
dbHost = cfg.database.postgresdb.host;
dbPort = toString cfg.database.postgresdb.port;
in ''
# Wait for PostgreSQL to be ready
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort}; do
sleep 1
done
# Create database user if it doesn't exist
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1 || \
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
# Grant privileges on database
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}"
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}"
'';
};
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
jq
];
};
}
@@ -0,0 +1,176 @@
# Custom n8n package with version selection
# Based on: https://github.com/NixOS/nixpkgs/blob/nixos-25.11/pkgs/by-name/n8/n8n/package.nix
#
# To add a new version:
# 1. Get the source hash:
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
# 2. Get the pnpm deps hash by running a build with lib.fakeHash and copying the correct hash from error
# 3. Add entry to versionHashes below
{
lib,
stdenv,
fetchFromGitHub,
nodejs,
pnpm_10,
fetchPnpmDeps,
pnpmConfigHook,
python3,
node-gyp,
cctools,
xcbuild,
libkrb5,
libmongocrypt,
libpq,
makeWrapper,
# Custom parameters
version ? "2.1.5",
buildMemoryMB ? 4096,
}:
let
# Known version hashes
# To add a new version, run:
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
# Then build with lib.fakeHash for pnpmDepsHash to get the correct hash
versionHashes = {
"2.1.5" = {
srcHash = "sha256-/MPY3j/2I3CgX5rRhzj3v7bHjaQEDMNnkVfk3taCrYA=";
pnpmDepsHash = "sha256-FRoZIINONy0kFPQAJhOwnCUv7HHwdgqm3r5SJmq4UYk=";
};
"2.1.4" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
"2.0.0" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
"1.120.4" = {
srcHash = "sha256-gUqQM/eA7GnvFYiduSGkj/MCvgWNQPhDLExAJz67bHg=";
pnpmDepsHash = "sha256-UWiN3NvI8We16KwY5JspyX0ok1PJWVg0T5zw+0SnrWk=";
};
"1.91.3" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
};
# Get hashes for the requested version
hashes = versionHashes.${version} or (throw ''
n8n version ${version} is not supported.
Supported versions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
To add support for version ${version}:
1. Get source hash: nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@${version}.tar.gz
2. Add entry to versionHashes in app_modules/_unstable/n8n/package.nix
3. Build once with placeholder pnpmDepsHash to get the correct hash from the error message
'');
in
stdenv.mkDerivation (finalAttrs: {
pname = "n8n";
inherit version;
src = fetchFromGitHub {
owner = "n8n-io";
repo = "n8n";
tag = "n8n@${finalAttrs.version}";
hash = hashes.srcHash;
};
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pnpm_10;
fetcherVersion = 2;
hash = hashes.pnpmDepsHash;
};
nativeBuildInputs = [
pnpmConfigHook
pnpm_10
python3 # required to build sqlite3 bindings
node-gyp # required to build sqlite3 bindings
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
cctools
xcbuild
];
buildInputs = [
nodejs
libkrb5
libmongocrypt
libpq
];
# Set memory limit for Node.js during build
env = {
NODE_OPTIONS = "--max-old-space-size=${toString buildMemoryMB}";
};
buildPhase = ''
runHook preBuild
pushd node_modules/sqlite3
node-gyp rebuild
popd
# TODO: use deploy after resolved https://github.com/pnpm/pnpm/issues/5315
pnpm build --filter=n8n
runHook postBuild
'';
preInstall = ''
echo "Removing non-deterministic and unnecessary files"
find -type d -name .turbo -exec rm -rf {} +
rm node_modules/.modules.yaml
rm -f packages/nodes-base/dist/types/nodes.json
CI=true pnpm --ignore-scripts prune --prod
find -type f \( -name "*.ts" -o -name "*.map" \) -exec rm -rf {} +
rm -rf node_modules/.pnpm/{typescript*,prettier*}
shopt -s globstar
# https://github.com/pnpm/pnpm/issues/3645
find node_modules packages/**/node_modules -xtype l -delete
echo "Removed non-deterministic and unnecessary files"
'';
installPhase = ''
runHook preInstall
mkdir -p $out/{bin,lib/n8n}
mv {packages,node_modules} $out/lib/n8n
makeWrapper $out/lib/n8n/packages/cli/bin/n8n $out/bin/n8n \
--set N8N_RELEASE_TYPE "stable"
runHook postInstall
'';
# this package has ~80000 files, these take too long and seem to be unnecessary
dontStrip = true;
dontPatchELF = true;
dontRewriteSymlinks = true;
meta = {
description = "Free and source-available fair-code licensed workflow automation tool";
longDescription = ''
Free and source-available fair-code licensed workflow automation tool.
Easily automate tasks across different services.
'';
homepage = "https://n8n.io";
changelog = "https://github.com/n8n-io/n8n/releases/tag/n8n@${finalAttrs.version}";
maintainers = with lib.maintainers; [
gepbird
AdrienLemaire
];
license = lib.licenses.sustainableUse;
mainProgram = "n8n";
platforms = lib.platforms.unix;
};
})
@@ -0,0 +1,8 @@
# Wrapper module that imports the unstable crowdsec module
# This allows the standard app_modules/default.nix import to work
{ ... }:
{
imports = [
../_unstable/crowdsec
];
}
+18
View File
@@ -0,0 +1,18 @@
{
imports = [
./elasticsearch
./haproxy
./home-assistant
./mariadb
./minio
./mongodb
./mongodb-pod
./n8n-pod
./nextcloud
./nginx
./opensearch
./postgresql
./rabbitmq
./redis
];
}
@@ -0,0 +1,100 @@
{ config, pkgs, lib, ... }:
let
appName = "elasticsearch";
defaultHttpPort = 9200;
defaultTransportPort = 9300;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.elasticsearch";
package = lib.mkOption {
type = lib.types.package;
description = "Elasticsearch package to use.";
default = pkgs.elasticsearch;
example = "pkgs.elasticsearch7";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind for HTTP API.";
default = "127.0.0.1";
};
httpPort = lib.mkOption {
type = lib.types.int;
description = "Port for HTTP API.";
default = defaultHttpPort;
};
transportPort = lib.mkOption {
type = lib.types.int;
description = "Port for transport/cluster communication.";
default = defaultTransportPort;
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Data directory for Elasticsearch.";
default = "/var/lib/elasticsearch";
};
clusterName = lib.mkOption {
type = lib.types.str;
description = "Name of the Elasticsearch cluster.";
default = "elasticsearch";
};
singleNode = lib.mkOption {
type = lib.types.bool;
description = "Run as a single-node cluster (disables bootstrap checks).";
default = true;
};
heapSize = lib.mkOption {
type = lib.types.str;
description = "JVM heap size for Elasticsearch (e.g., '512m', '1g').";
default = "512m";
};
extraSettings = lib.mkOption {
type = lib.types.attrs;
description = "Extra settings to add to elasticsearch.yml.";
default = {};
example = { "action.destructive_requires_name" = true; };
};
};
config = lib.mkIf cfg.enable {
services.elasticsearch = {
enable = true;
package = cfg.package;
dataDir = cfg.dataDir;
cluster_name = cfg.clusterName;
listenAddress = cfg.bindToIp;
port = cfg.httpPort;
tcp_port = cfg.transportPort;
single_node = cfg.singleNode;
extraConf = lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name}: ${builtins.toJSON value}") cfg.extraSettings
);
extraJavaOptions = [
"-Xms${cfg.heapSize}"
"-Xmx${cfg.heapSize}"
];
};
# Install curl for API access
environment.systemPackages = [ pkgs.curl pkgs.jq ];
# Open firewall for Elasticsearch if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.httpPort
cfg.transportPort
];
};
}
@@ -0,0 +1,718 @@
{ config, pkgs, lib, ... }:
let
appName = "haproxy";
cfg = config.infrastructure.${appName};
# Generate combined PEM file path for a domain
combinedPemPath = domain: "/var/lib/acme/${domain}/combined.pem";
# Self-signed certificate directory
selfSignedCertDir = "/var/lib/haproxy/certs";
# Script to concatenate fullchain.pem and privkey.pem for HAProxy
# HAProxy requires a single file with cert chain + private key
mkCombinePemScript = domain: pkgs.writeShellScript "combine-pem-${domain}" ''
ACME_DIR="/var/lib/acme/${domain}"
COMBINED="$ACME_DIR/combined.pem"
if [ -f "$ACME_DIR/fullchain.pem" ] && [ -f "$ACME_DIR/privkey.pem" ]; then
cat "$ACME_DIR/fullchain.pem" "$ACME_DIR/privkey.pem" > "$COMBINED"
chmod 640 "$COMBINED"
chown acme:haproxy "$COMBINED"
fi
'';
# Script to generate self-signed certificates for testing
mkSelfSignedCertScript = domain: pkgs.writeShellScript "generate-self-signed-${domain}" ''
CERT_DIR="${selfSignedCertDir}"
COMBINED="$CERT_DIR/${domain}.pem"
mkdir -p "$CERT_DIR"
# Only generate if not exists or expired
if [ ! -f "$COMBINED" ] || ! ${pkgs.openssl}/bin/openssl x509 -checkend 86400 -noout -in "$COMBINED" 2>/dev/null; then
echo "Generating self-signed certificate for ${domain}..."
${pkgs.openssl}/bin/openssl req -x509 -newkey rsa:4096 \
-keyout "$CERT_DIR/${domain}.key" \
-out "$CERT_DIR/${domain}.crt" \
-sha256 -days 365 -nodes \
-subj "/CN=${domain}" \
-addext "subjectAltName=DNS:${domain},DNS:*.${domain}"
# Combine for HAProxy
cat "$CERT_DIR/${domain}.crt" "$CERT_DIR/${domain}.key" > "$COMBINED"
chmod 640 "$COMBINED"
chown haproxy:haproxy "$COMBINED"
rm -f "$CERT_DIR/${domain}.key" "$CERT_DIR/${domain}.crt"
fi
'';
# Default HAProxy global configuration
defaultGlobalConfig = ''
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
# Modern SSL settings
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11
tune.ssl.default-dh-param 2048
'';
# Default HAProxy defaults configuration
defaultDefaultsConfig = ''
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 50s
timeout server 50s
timeout http-request 10s
timeout http-keep-alive 10s
errorfile 400 /dev/null
errorfile 403 /dev/null
errorfile 408 /dev/null
errorfile 500 /dev/null
errorfile 502 /dev/null
errorfile 503 /dev/null
errorfile 504 /dev/null
'';
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.haproxy";
package = lib.mkOption {
type = lib.types.package;
description = "HAProxy package to use.";
default = pkgs.haproxy;
example = "pkgs.haproxy-lts";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
default = true;
};
user = lib.mkOption {
type = lib.types.str;
description = "User account under which HAProxy runs.";
default = "haproxy";
};
group = lib.mkOption {
type = lib.types.str;
description = "Group account under which HAProxy runs.";
default = "haproxy";
};
# ==========================================================================
# Let's Encrypt / ACME Configuration
# ==========================================================================
acme = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable ACME (Let's Encrypt) certificate management.";
default = false;
};
acceptTerms = lib.mkOption {
type = lib.types.bool;
description = ''
Accept the ACME provider's terms of service.
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
'';
default = false;
};
email = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default email address for ACME certificate registration and renewal notifications.";
default = null;
example = "admin@example.com";
};
staging = lib.mkOption {
type = lib.types.bool;
description = ''
Use Let's Encrypt staging server for testing.
Certificates won't be trusted but you won't hit rate limits.
'';
default = false;
};
domains = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
extraDomainNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional domain names (SANs) for this certificate.";
default = [];
example = [ "www.example.com" "api.example.com" ];
};
webroot = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Webroot path for HTTP-01 challenge. If null, standalone mode is used.";
default = "/var/lib/acme/acme-challenge";
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = "Extra configuration options for this certificate.";
default = {};
};
};
});
description = ''
Domains to obtain certificates for. The key is the primary domain name.
Use extraDomainNames for additional SANs (Subject Alternative Names).
'';
default = {};
example = lib.literalExpression ''
{
"example.com" = {
extraDomainNames = [ "www.example.com" ];
};
"api.example.com" = {};
}
'';
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed to security.acme.defaults.
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
'';
default = {};
example = {
renewInterval = "daily";
};
};
};
# ==========================================================================
# Self-Signed Certificate Configuration (for testing)
# ==========================================================================
selfSigned = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable self-signed certificate generation for testing.
These certificates are NOT trusted by browsers but useful for development/testing.
'';
default = false;
};
domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of domains to generate self-signed certificates for.";
default = [];
example = [ "localhost" "test.local" ];
};
regenerate = lib.mkOption {
type = lib.types.bool;
description = "Force regeneration of self-signed certificates on each activation.";
default = false;
};
};
# ==========================================================================
# SSL/TLS Configuration
# ==========================================================================
ssl = {
minVersion = lib.mkOption {
type = lib.types.enum [ "TLSv1.2" "TLSv1.3" ];
description = "Minimum TLS version to accept.";
default = "TLSv1.2";
};
ciphers = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Custom cipher suite for TLS 1.2 and below.";
default = null;
example = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256";
};
ciphersuites = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Custom cipher suite for TLS 1.3.";
default = null;
example = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384";
};
hsts = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable HTTP Strict Transport Security (HSTS) header.";
default = false;
};
maxAge = lib.mkOption {
type = lib.types.int;
description = "HSTS max-age in seconds.";
default = 31536000; # 1 year
};
includeSubDomains = lib.mkOption {
type = lib.types.bool;
description = "Include subdomains in HSTS policy.";
default = true;
};
preload = lib.mkOption {
type = lib.types.bool;
description = "Add preload directive to HSTS header.";
default = false;
};
};
};
# ==========================================================================
# HTTP to HTTPS Redirect
# ==========================================================================
httpToHttpsRedirect = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Automatically redirect HTTP requests to HTTPS.
Creates a frontend on port 80 that redirects all traffic to HTTPS.
'';
default = false;
};
code = lib.mkOption {
type = lib.types.enum [ 301 302 307 308 ];
description = "HTTP redirect status code to use.";
default = 301;
};
excludePaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Paths to exclude from redirect (e.g., ACME challenge).";
default = [ "/.well-known/acme-challenge/" ];
};
};
# ==========================================================================
# HAProxy Configuration
# ==========================================================================
globalConfig = lib.mkOption {
type = lib.types.lines;
description = "HAProxy global section configuration.";
default = defaultGlobalConfig;
example = ''
global
log /dev/log local0
maxconn 2048
'';
};
defaultsConfig = lib.mkOption {
type = lib.types.lines;
description = "HAProxy defaults section configuration.";
default = defaultDefaultsConfig;
example = ''
defaults
log global
mode http
timeout connect 5s
timeout client 50s
timeout server 50s
'';
};
frontends = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
bind = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Bind addresses and ports.";
default = [];
example = [ "*:80" "*:443 ssl crt /path/to/cert.pem" ];
};
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Frontend mode.";
default = "http";
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this frontend.";
default = [];
example = [ "httplog" "forwardfor" ];
};
acls = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "ACL definitions.";
default = [];
example = [ "is_api path_beg /api" "is_static path_beg /static" ];
};
httpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-request rules.";
default = [];
example = [ "set-header X-Forwarded-Proto https if { ssl_fc }" ];
};
httpResponse = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-response rules.";
default = [];
example = [ "set-header Strict-Transport-Security max-age=31536000" ];
};
tcpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "tcp-request rules (for TCP mode).";
default = [];
example = [ "inspect-delay 5s" "content accept if { req_ssl_hello_type 1 }" ];
};
useBackend = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "use_backend rules.";
default = [];
example = [ "api_backend if is_api" "static_backend if is_static" ];
};
defaultBackend = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default backend for this frontend.";
default = null;
example = "web_backend";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this frontend.";
default = "";
};
};
});
description = "HAProxy frontend configurations.";
default = {};
example = lib.literalExpression ''
{
http = {
bind = [ "*:80" ];
defaultBackend = "web_backend";
};
https = {
bind = [ "*:443 ssl crt /var/lib/acme/example.com/combined.pem" ];
httpRequest = [ "set-header X-Forwarded-Proto https" ];
defaultBackend = "web_backend";
};
}
'';
};
backends = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Backend mode.";
default = "http";
};
balance = lib.mkOption {
type = lib.types.str;
description = "Load balancing algorithm.";
default = "roundrobin";
example = "leastconn";
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this backend.";
default = [];
example = [ "httpchk GET /health" ];
};
httpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-request rules for this backend.";
default = [];
};
httpResponse = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-response rules for this backend.";
default = [];
};
tcpCheck = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "tcp-check rules for TCP mode health checking.";
default = [];
example = [ "connect" "send PING\\r\\n" "expect string +PONG" ];
};
servers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Backend server definitions.";
default = [];
example = [ "server1 127.0.0.1:8080 check" "server2 127.0.0.1:8081 check" ];
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this backend.";
default = "";
};
};
});
description = "HAProxy backend configurations.";
default = {};
example = lib.literalExpression ''
{
web_backend = {
balance = "roundrobin";
servers = [ "web1 127.0.0.1:8080 check" "web2 127.0.0.1:8081 check" ];
options = [ "httpchk GET /health" ];
};
}
'';
};
listen = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
bind = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Bind addresses and ports.";
default = [];
};
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Listen mode.";
default = "http";
};
balance = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Load balancing algorithm.";
default = null;
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this listen section.";
default = [];
};
servers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Server definitions.";
default = [];
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this listen section.";
default = "";
};
};
});
description = "HAProxy listen sections (combined frontend/backend).";
default = {};
example = lib.literalExpression ''
{
stats = {
bind = [ "*:8404" ];
options = [ "http-use-htx" "httplog" ];
extraConfig = '''
stats enable
stats uri /stats
stats refresh 10s
''';
};
}
'';
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra HAProxy configuration appended to the config file.";
default = "";
};
};
config = lib.mkIf cfg.enable {
# Assertions
assertions = [
{
assertion = !(cfg.acme.enable && cfg.selfSigned.enable);
message = "Cannot enable both ACME and self-signed certificates. Choose one.";
}
{
assertion = cfg.acme.enable -> cfg.acme.acceptTerms;
message = "You must accept the ACME terms of service to use Let's Encrypt.";
}
{
assertion = cfg.acme.enable -> cfg.acme.email != null;
message = "You must provide an email address for ACME certificate registration.";
}
];
# ACME configuration for Let's Encrypt
security.acme = lib.mkIf cfg.acme.enable {
acceptTerms = cfg.acme.acceptTerms;
defaults = {
email = cfg.acme.email;
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
webroot = "/var/lib/acme/acme-challenge";
group = "haproxy";
} // cfg.acme.extraConfig;
# Create certificate configurations for each domain
certs = lib.mapAttrs (domain: domainCfg: {
inherit (domainCfg) extraDomainNames;
webroot = domainCfg.webroot;
# Reload HAProxy after certificate renewal
postRun = ''
# Combine fullchain and privkey for HAProxy
${mkCombinePemScript domain}
# Reload HAProxy to pick up new certificates
${pkgs.systemd}/bin/systemctl reload haproxy.service || true
'';
} // domainCfg.extraConfig) cfg.acme.domains;
};
# Ensure haproxy user is in acme group to read certificates
users.users.haproxy = lib.mkIf cfg.acme.enable {
extraGroups = [ "acme" ];
};
# Create directories for ACME and self-signed certificates
systemd.tmpfiles.rules =
lib.optionals cfg.acme.enable [
"d /var/lib/acme/acme-challenge 0755 acme acme -"
"d /var/lib/acme/acme-challenge/.well-known 0755 acme acme -"
"d /var/lib/acme/acme-challenge/.well-known/acme-challenge 0755 acme acme -"
] ++
lib.optionals cfg.selfSigned.enable [
"d ${selfSignedCertDir} 0750 haproxy haproxy -"
];
# Self-signed certificate generation service
systemd.services.haproxy-generate-self-signed = lib.mkIf cfg.selfSigned.enable {
description = "Generate self-signed certificates for HAProxy";
wantedBy = [ "haproxy.service" ];
before = [ "haproxy.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = lib.concatMapStringsSep "\n" (domain:
"${mkSelfSignedCertScript domain}"
) cfg.selfSigned.domains;
};
# HAProxy configuration
services.haproxy = {
enable = true;
package = cfg.package;
user = cfg.user;
group = cfg.group;
config = let
# HSTS header value
hstsHeader = lib.optionalString cfg.ssl.hsts.enable (
"max-age=${toString cfg.ssl.hsts.maxAge}" +
lib.optionalString cfg.ssl.hsts.includeSubDomains "; includeSubDomains" +
lib.optionalString cfg.ssl.hsts.preload "; preload"
);
# HTTP to HTTPS redirect frontend
httpRedirectFrontend = lib.optionalString cfg.httpToHttpsRedirect.enable ''
frontend http-redirect
bind *:80
mode http
${lib.concatMapStringsSep "\n " (path: "acl is_acme path_beg ${path}") cfg.httpToHttpsRedirect.excludePaths}
${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) "use_backend acme_backend if is_acme"}
http-request redirect scheme https code ${toString cfg.httpToHttpsRedirect.code} unless { ssl_fc }${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) " or is_acme"}
'';
# Generate frontend configuration
frontendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: frontend: ''
frontend ${name}
${lib.concatMapStringsSep "\n " (b: "bind ${b}") frontend.bind}
mode ${frontend.mode}
${lib.concatMapStringsSep "\n " (o: "option ${o}") frontend.options}
${lib.concatMapStringsSep "\n " (a: "acl ${a}") frontend.acls}
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") frontend.httpRequest}
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") frontend.httpResponse}
${lib.concatMapStringsSep "\n " (r: "tcp-request ${r}") frontend.tcpRequest}
${lib.optionalString (cfg.ssl.hsts.enable && frontend.mode == "http") "http-response set-header Strict-Transport-Security \"${hstsHeader}\""}
${lib.concatMapStringsSep "\n " (u: "use_backend ${u}") frontend.useBackend}
${lib.optionalString (frontend.defaultBackend != null) "default_backend ${frontend.defaultBackend}"}
${frontend.extraConfig}
'') cfg.frontends);
# Generate backend configuration
backendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: backend: ''
backend ${name}
mode ${backend.mode}
balance ${backend.balance}
${lib.concatMapStringsSep "\n " (o: "option ${o}") backend.options}
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") backend.httpRequest}
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") backend.httpResponse}
${lib.concatMapStringsSep "\n " (c: "tcp-check ${c}") backend.tcpCheck}
${lib.concatMapStringsSep "\n " (s: "server ${s}") backend.servers}
${backend.extraConfig}
'') cfg.backends);
# Generate listen configuration
listenConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: listenCfg: ''
listen ${name}
${lib.concatMapStringsSep "\n " (b: "bind ${b}") listenCfg.bind}
mode ${listenCfg.mode}
${lib.optionalString (listenCfg.balance != null) "balance ${listenCfg.balance}"}
${lib.concatMapStringsSep "\n " (o: "option ${o}") listenCfg.options}
${lib.concatMapStringsSep "\n " (s: "server ${s}") listenCfg.servers}
${listenCfg.extraConfig}
'') cfg.listen);
in ''
${cfg.globalConfig}
${cfg.defaultsConfig}
${httpRedirectFrontend}
${frontendConfigs}
${backendConfigs}
${listenConfigs}
${cfg.extraConfig}
'';
};
# Ensure HAProxy starts after certificates are ready
systemd.services.haproxy = lib.mkMerge [
(lib.mkIf cfg.acme.enable {
wants = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
after = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
})
(lib.mkIf cfg.selfSigned.enable {
wants = [ "haproxy-generate-self-signed.service" ];
after = [ "haproxy-generate-self-signed.service" ];
})
{
serviceConfig = {
# Allow HAProxy to reload without restart
ExecReload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
};
}
];
# Open firewall for HTTP/HTTPS
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
# Install useful utilities
environment.systemPackages = [ cfg.package pkgs.curl pkgs.openssl ];
};
}
@@ -0,0 +1,284 @@
{ config, pkgs, lib, ... }:
let
appName = "home-assistant";
defaultPort = 8123;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.home-assistant";
package = lib.mkOption {
type = lib.types.package;
description = "Home Assistant package to use.";
default = pkgs.home-assistant;
example = "pkgs.home-assistant";
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind Home Assistant to.";
default = "0.0.0.0";
example = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for Home Assistant web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for Home Assistant.";
default = true;
};
# ==========================================================================
# Configuration Directory
# ==========================================================================
configDir = lib.mkOption {
type = lib.types.path;
description = "Directory where Home Assistant configuration is stored.";
default = "/var/lib/hass";
};
configWritable = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to make configuration.yaml writable from the web UI.
This allows editing configuration from Home Assistant's interface.
'';
default = false;
};
# ==========================================================================
# Components and Integrations
# ==========================================================================
extraComponents = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of Home Assistant components/integrations to include.
Component names can be found at https://www.home-assistant.io/integrations/
'';
default = [
# Components required for initial onboarding
"esphome"
"met"
"radio_browser"
];
example = [
"esphome"
"met"
"radio_browser"
"hue"
"zwave_js"
"mqtt"
"homekit"
];
};
customComponents = lib.mkOption {
type = lib.types.listOf lib.types.package;
description = "List of custom component packages to install.";
default = [];
};
customLovelaceModules = lib.mkOption {
type = lib.types.listOf lib.types.package;
description = "List of custom Lovelace card packages to load.";
default = [];
};
# ==========================================================================
# Home Assistant Configuration (config.yaml as Nix)
# ==========================================================================
config = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
description = ''
Home Assistant configuration.yaml as a Nix attribute set.
Set to null to use an existing configuration.yaml file.
'';
default = {
# Basic setup with default_config integration
default_config = {};
# HTTP configuration
http = {
server_host = cfg.bindToIp;
server_port = cfg.bindToPort;
};
# Homeassistant core settings
homeassistant = {
name = "Home";
unit_system = "metric";
};
};
example = lib.literalExpression ''
{
default_config = {};
homeassistant = {
name = "My Smart Home";
unit_system = "metric";
time_zone = "Europe/London";
latitude = 51.5074;
longitude = -0.1278;
};
automation = "!include automations.yaml";
scene = "!include scenes.yaml";
}
'';
};
# ==========================================================================
# Lovelace Dashboard Configuration
# ==========================================================================
lovelaceConfig = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
description = ''
Lovelace dashboard configuration as a Nix attribute set.
Set to null to use UI-managed dashboards.
'';
default = null;
};
lovelaceConfigWritable = lib.mkOption {
type = lib.types.bool;
description = "Whether to make Lovelace configuration writable.";
default = false;
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for Home Assistant.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "homeassistant.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
trustedProxies = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of trusted proxy IP addresses.";
default = [ "127.0.0.1" "::1" ];
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Home Assistant Service Configuration
# ==========================================================================
services.home-assistant = {
enable = true;
package = cfg.package;
configDir = cfg.configDir;
configWritable = cfg.configWritable;
# Components and integrations
extraComponents = cfg.extraComponents;
customComponents = cfg.customComponents;
customLovelaceModules = cfg.customLovelaceModules;
# Configuration
config = if cfg.config != null then (cfg.config // {
# Always include HTTP config if using reverse proxy
http = (cfg.config.http or {}) // (lib.mkIf cfg.reverseProxy.enable {
use_x_forwarded_for = true;
trusted_proxies = cfg.reverseProxy.trustedProxies;
});
}) else null;
# Lovelace configuration
lovelaceConfig = cfg.lovelaceConfig;
lovelaceConfigWritable = cfg.lovelaceConfigWritable;
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
extraConfig = ''
proxy_buffering off;
'';
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.bindToPort}";
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;
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Service Dependencies
# ==========================================================================
systemd.services.home-assistant = {
after = lib.mkIf cfg.reverseProxy.enable [ "nginx.service" ];
};
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
wants = [ "home-assistant.service" ];
};
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
];
};
}
@@ -0,0 +1,107 @@
{ config, pkgs, lib, ... }:
let
appName = "mariadb";
appPort = 3306;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mariadb";
package = lib.mkOption {
type = lib.types.package;
description = "MariaDB package to use.";
default = pkgs.mariadb;
example = "pkgs.mariadb_110";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
initialDatabases = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Database name.";
};
schema = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "Path to SQL schema file to import.";
default = null;
};
};
});
description = "List of databases to create on initialization.";
default = [];
example = [ { name = "myapp"; } { name = "testdb"; schema = ./schema.sql; } ];
};
ensureUsers = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "User name.";
};
ensurePermissions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Permissions to grant to the user.";
default = {};
example = { "*.*" = "ALL PRIVILEGES"; };
};
};
});
description = ''
List of users to ensure exist. Users are created with Unix socket
authentication by default (no password required for local connections
when the system username matches the MySQL username).
'';
default = [];
example = [ { name = "myuser"; ensurePermissions = { "mydb.*" = "ALL PRIVILEGES"; }; } ];
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = "Additional MariaDB settings.";
default = {};
example = {
max_connections = 200;
innodb_buffer_pool_size = "1G";
};
};
};
config = lib.mkIf cfg.enable {
services.mysql = {
enable = true;
package = cfg.package;
settings = {
mysqld = {
bind-address = cfg.bindToIp;
port = cfg.bindToPort;
} // cfg.settings;
};
# Create initial databases if specified
initialDatabases = cfg.initialDatabases;
# Create users if specified
ensureUsers = cfg.ensureUsers;
};
# Open firewall for MariaDB if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,100 @@
{ config, pkgs, lib, ... }:
let
appName = "minio";
defaultApiPort = 9000;
defaultConsolePort = 9001;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.minio";
package = lib.mkOption {
type = lib.types.package;
description = "MinIO package to use.";
default = pkgs.minio;
example = "pkgs.minio";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
apiPort = lib.mkOption {
type = lib.types.int;
description = "Port for S3 API.";
default = defaultApiPort;
};
consolePort = lib.mkOption {
type = lib.types.int;
description = "Port for web console.";
default = defaultConsolePort;
};
dataDir = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Data directories for MinIO storage.";
default = [ "/var/lib/minio/data" ];
example = [ "/var/lib/minio/data1" "/var/lib/minio/data2" ];
};
configDir = lib.mkOption {
type = lib.types.str;
description = "Configuration directory for MinIO.";
default = "/var/lib/minio/config";
};
rootCredentialsSecretName = lib.mkOption {
type = lib.types.str;
description = ''
Name of the secret containing root credentials.
The secret file should contain:
MINIO_ROOT_USER=<user>
MINIO_ROOT_PASSWORD=<password>
The secret will be loaded from:
/run/secrets/<secretName>
'';
example = "minio-root-credentials";
};
region = lib.mkOption {
type = lib.types.str;
description = "Region for MinIO server.";
default = "us-east-1";
};
browser = lib.mkOption {
type = lib.types.bool;
description = "Enable or disable the web browser console.";
default = true;
};
};
config = lib.mkIf cfg.enable {
services.minio = {
enable = true;
package = cfg.package;
listenAddress = "${cfg.bindToIp}:${toString cfg.apiPort}";
consoleAddress = "${cfg.bindToIp}:${toString cfg.consolePort}";
dataDir = cfg.dataDir;
configDir = cfg.configDir;
rootCredentialsFile = "/run/secrets/${cfg.rootCredentialsSecretName}";
region = cfg.region;
browser = cfg.browser;
};
# Install MinIO client for CLI access
environment.systemPackages = [ pkgs.minio-client pkgs.curl pkgs.jq ];
# Open firewall for MinIO if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.apiPort
cfg.consolePort
];
};
}
@@ -0,0 +1,61 @@
{ config, pkgs, lib, ... }:
let
appName = "mongodb-pod";
appPort = 27017;
cfg = config.infrastructure.${appName};
dataDir = "/var/lib/mongodb-pod";
execStartPreScript = pkgs.writeShellScript "preStart" ''
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
'';
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mongodb-pod oci";
image = lib.mkOption {
type = lib.types.str;
description = "MongoDB Docker image to use.";
default = "mongo:6";
example = "mongo:4.4.29-focal";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
};
config = lib.mkIf cfg.enable {
# https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up
infrastructure.oci-containers.backend = "podman";
infrastructure.oci-containers.containers.${appName} = {
app = {
name = appName;
};
image = cfg.image;
autoStart = true;
ports = [
"${cfg.bindToIp}:${toString cfg.bindToPort}:27017"
];
bindToIp = cfg.bindToIp;
volumes = [
"${dataDir}:/data/db"
];
execHooks = {
ExecStartPre = [
"${execStartPreScript}"
];
};
};
};
}
@@ -0,0 +1,48 @@
{ config, pkgs, lib, ... }:
let
appName = "mongodb";
defaultPort = 27017;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mongodb";
package = lib.mkOption {
type = lib.types.package;
description = "MongoDB package to use.";
default = pkgs.mongodb-ce;
example = "pkgs.mongodb";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = defaultPort;
};
};
config = lib.mkIf cfg.enable {
services.mongodb = {
enable = true;
package = cfg.package;
bind_ip = cfg.bindToIp;
extraConfig = lib.mkIf (cfg.bindToPort != defaultPort) ''
net.port: ${toString cfg.bindToPort}
'';
};
# Install mongosh for CLI access
environment.systemPackages = [ pkgs.mongosh ];
# Open firewall for MongoDB if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,234 @@
{ config, pkgs, lib, ... }:
let
appName = "n8n-pod";
defaultPort = 5678;
cfg = config.infrastructure.${appName};
dataDir = "/var/lib/n8n-pod";
execStartPreScript = pkgs.writeShellScript "preStart" ''
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
${pkgs.coreutils}/bin/chown -R 1000:1000 ${dataDir}
'';
# Build environment variables for the container
containerEnv = {
# Network settings
N8N_PORT = toString defaultPort;
N8N_LISTEN_ADDRESS = "0.0.0.0"; # Always bind to all interfaces inside container
# Execution settings
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
WEBHOOK_URL = cfg.webhookUrl;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
DB_TYPE = "postgresdb";
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
DB_POSTGRESDB_SSL_ENABLED = "true";
}) // cfg.settings;
# Convert environment to list of "KEY=VALUE" strings
envList = lib.mapAttrsToList (name: value: "${name}=${toString value}") containerEnv;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.n8n-pod oci";
image = lib.mkOption {
type = lib.types.str;
description = "n8n Docker image to use.";
default = "docker.n8n.io/n8nio/n8n:latest";
example = "docker.n8n.io/n8nio/n8n:1.70.0";
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind n8n to on the host.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for n8n web interface on the host.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for n8n.";
default = false;
};
# ==========================================================================
# Webhook Configuration
# ==========================================================================
webhookUrl = lib.mkOption {
type = lib.types.str;
description = ''
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
This is the external URL where webhooks can reach n8n.
'';
default = "";
example = "https://n8n.example.com/";
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "postgresdb" ];
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
default = "sqlite";
};
postgresdb = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host. Use host IP for container access.";
default = "host.containers.internal";
example = "192.168.1.100";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
database = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "n8n";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "n8n";
};
password = lib.mkOption {
type = lib.types.str;
description = ''
PostgreSQL password. For production, consider using
passwordFile or environment variable injection instead.
'';
default = "";
example = "secretpassword";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL for PostgreSQL connection.";
default = false;
};
};
};
# ==========================================================================
# Execution Configuration
# ==========================================================================
executions = {
pruneData = lib.mkOption {
type = lib.types.bool;
description = "Enable automatic pruning of old execution data.";
default = true;
};
pruneDataMaxAge = lib.mkOption {
type = lib.types.int;
description = "Maximum age of execution data in hours before pruning.";
default = 336; # 14 days
};
pruneDataMaxCount = lib.mkOption {
type = lib.types.int;
description = "Maximum number of executions to keep.";
default = 10000;
};
};
# ==========================================================================
# n8n Settings (pass-through as environment variables)
# ==========================================================================
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional n8n configuration as environment variables.
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
'';
default = {};
example = lib.literalExpression ''
{
GENERIC_TIMEZONE = "Europe/London";
WORKFLOWS_DEFAULT_NAME = "My Workflow";
N8N_METRICS = "true";
}
'';
};
};
config = lib.mkIf cfg.enable {
# Configure podman backend
infrastructure.oci-containers.backend = "podman";
infrastructure.oci-containers.containers.${appName} = {
app = {
name = appName;
};
image = cfg.image;
autoStart = true;
ports = [
"${cfg.bindToIp}:${toString cfg.bindToPort}:${toString defaultPort}"
];
bindToIp = cfg.bindToIp;
# Mount data directory for persistence
volumes = [
"${dataDir}:/home/node/.n8n"
];
# Environment variables
environment = containerEnv;
# Run as node user (UID 1000 in official image)
user = "1000:1000";
execHooks = {
ExecStartPre = [
"${execStartPreScript}"
];
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.bindToPort ];
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
jq
];
};
}
@@ -0,0 +1,273 @@
{ config, pkgs, lib, ... }:
let
appName = "nextcloud";
defaultPort = 80;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.nextcloud";
package = lib.mkOption {
type = lib.types.package;
description = "Nextcloud package to use.";
default = pkgs.nextcloud31;
example = "pkgs.nextcloud30";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for Nextcloud.";
default = "localhost";
example = "cloud.example.com";
};
https = lib.mkOption {
type = lib.types.bool;
description = "Whether to use HTTPS.";
default = false;
};
# ==========================================================================
# Admin Configuration
# ==========================================================================
admin = {
user = lib.mkOption {
type = lib.types.str;
description = "Admin username.";
default = "admin";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Path to file containing admin password.";
};
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "pgsql" "mysql" ];
description = "Database type to use.";
default = "pgsql";
};
name = lib.mkOption {
type = lib.types.str;
description = "Database name.";
default = "nextcloud";
};
user = lib.mkOption {
type = lib.types.str;
description = "Database user.";
default = "nextcloud";
};
host = lib.mkOption {
type = lib.types.str;
description = "Database host. Use socket path for local connections.";
default = "/run/postgresql";
example = "127.0.0.1";
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database and user locally.
Only works for PostgreSQL and MySQL/MariaDB when using socket authentication.
'';
default = true;
};
};
# ==========================================================================
# Caching Configuration
# ==========================================================================
caching = {
redis = lib.mkOption {
type = lib.types.bool;
description = "Enable Redis for caching and file locking.";
default = false;
};
apcu = lib.mkOption {
type = lib.types.bool;
description = "Enable APCu for local caching.";
default = true;
};
memcached = lib.mkOption {
type = lib.types.bool;
description = "Enable Memcached for distributed caching.";
default = false;
};
};
# ==========================================================================
# PHP Configuration
# ==========================================================================
maxUploadSize = lib.mkOption {
type = lib.types.str;
description = "Maximum upload size.";
default = "512M";
example = "1G";
};
phpOptions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Additional PHP options.";
default = {};
example = {
"opcache.interned_strings_buffer" = "16";
"opcache.max_accelerated_files" = "10000";
};
};
# ==========================================================================
# Extra Configuration
# ==========================================================================
extraApps = lib.mkOption {
type = lib.types.attrsOf lib.types.package;
description = "Extra Nextcloud apps to install.";
default = {};
example = lib.literalExpression ''
with config.services.nextcloud.package.packages.apps; {
inherit calendar contacts notes;
}
'';
};
extraAppsEnable = lib.mkOption {
type = lib.types.bool;
description = "Automatically enable extra apps.";
default = true;
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional Nextcloud configuration settings.
These are passed directly to services.nextcloud.settings.
'';
default = {};
example = {
default_phone_region = "US";
overwriteprotocol = "https";
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Nextcloud Service Configuration
# ==========================================================================
services.nextcloud = {
enable = true;
package = cfg.package;
hostName = cfg.hostName;
https = cfg.https;
# Admin configuration
config = {
adminuser = cfg.admin.user;
adminpassFile = cfg.admin.passwordFile;
# Database configuration
dbtype = cfg.database.type;
dbname = cfg.database.name;
dbuser = cfg.database.user;
dbhost = cfg.database.host;
};
# Database creation
database.createLocally = cfg.database.createLocally;
# Caching configuration
caching = {
redis = cfg.caching.redis;
apcu = cfg.caching.apcu;
memcached = cfg.caching.memcached;
};
# Configure Redis for file locking if enabled
configureRedis = cfg.caching.redis;
# PHP settings
maxUploadSize = cfg.maxUploadSize;
phpOptions = {
"opcache.enable" = "1";
"opcache.enable_cli" = "1";
"opcache.interned_strings_buffer" = "8";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.save_comments" = "1";
"opcache.revalidate_freq" = "1";
} // cfg.phpOptions;
# Extra apps
extraApps = cfg.extraApps;
extraAppsEnable = cfg.extraAppsEnable;
# Additional settings
settings = {
default_phone_region = "US";
maintenance_window_start = 1;
} // cfg.settings;
};
# ==========================================================================
# Nginx Configuration (automatically enabled by Nextcloud module)
# ==========================================================================
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
};
# ==========================================================================
# Service Dependencies
# ==========================================================================
# Ensure Nextcloud starts after its dependencies
systemd.services.nextcloud-setup = {
after = lib.mkMerge [
# Database dependencies
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
# Redis dependency
(lib.mkIf cfg.caching.redis [ "redis-nextcloud.service" ])
];
requires = lib.mkMerge [
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
];
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = [ 80 443 ];
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
];
};
}
@@ -0,0 +1,186 @@
{ config, pkgs, lib, ... }:
let
appName = "nginx";
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.nginx";
package = lib.mkOption {
type = lib.types.package;
description = "Nginx package to use.";
default = pkgs.nginx;
example = "pkgs.nginxMainline";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
default = true;
};
recommendedSettings = lib.mkOption {
type = lib.types.bool;
description = ''
Enable recommended nginx settings for optimization and security.
This enables recommendedGzipSettings, recommendedOptimisation,
recommendedProxySettings, and recommendedTlsSettings.
'';
default = true;
};
# ==========================================================================
# Let's Encrypt / ACME Configuration
# ==========================================================================
acme = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable ACME (Let's Encrypt) certificate management.";
default = false;
};
acceptTerms = lib.mkOption {
type = lib.types.bool;
description = ''
Accept the ACME provider's terms of service.
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
'';
default = false;
};
email = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default email address for ACME certificate registration and renewal notifications.";
default = null;
example = "admin@example.com";
};
staging = lib.mkOption {
type = lib.types.bool;
description = ''
Use Let's Encrypt staging server for testing.
Certificates won't be trusted but you won't hit rate limits.
'';
default = false;
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed to security.acme.defaults.
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
'';
default = {};
example = {
webroot = "/var/lib/acme/acme-challenge";
renewInterval = "daily";
};
};
};
# ==========================================================================
# Pass-through Configuration
# ==========================================================================
virtualHosts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
# Use freeformType to allow any nginx virtualHost options
freeformType = lib.types.attrsOf lib.types.anything;
});
description = ''
Virtual host configurations passed directly to services.nginx.virtualHosts.
See https://nixos.org/manual/nixos/stable/options.html#opt-services.nginx.virtualHosts
Example with Let's Encrypt:
{
"example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
};
};
}
'';
default = {};
example = lib.literalExpression ''
{
"example.com" = {
enableACME = true;
forceSSL = true;
root = "/var/www/example.com";
};
"api.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
proxyWebsockets = true;
};
};
}
'';
};
appendHttpConfig = lib.mkOption {
type = lib.types.lines;
description = "Additional nginx http block configuration.";
default = "";
example = ''
proxy_buffer_size 128k;
proxy_buffers 4 256k;
'';
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed directly to services.nginx.
Use this for any nginx options not explicitly exposed by this module.
'';
default = {};
example = {
clientMaxBodySize = "100m";
resolver = { addresses = [ "1.1.1.1" ]; };
};
};
};
config = lib.mkIf cfg.enable {
# ACME configuration
security.acme = lib.mkIf cfg.acme.enable {
acceptTerms = cfg.acme.acceptTerms;
defaults = {
email = cfg.acme.email;
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
} // cfg.acme.extraConfig;
};
# Nginx configuration
services.nginx = {
enable = true;
package = cfg.package;
# Recommended settings
recommendedGzipSettings = cfg.recommendedSettings;
recommendedOptimisation = cfg.recommendedSettings;
recommendedProxySettings = cfg.recommendedSettings;
recommendedTlsSettings = cfg.recommendedSettings;
# Virtual hosts (pass-through)
virtualHosts = cfg.virtualHosts;
# Additional http config
appendHttpConfig = cfg.appendHttpConfig;
} // cfg.extraConfig;
# Open firewall for HTTP/HTTPS
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
# Install useful utilities
environment.systemPackages = [ pkgs.curl pkgs.openssl ];
};
}
@@ -0,0 +1,104 @@
{ config, pkgs, lib, ... }:
let
appName = "opensearch";
defaultHttpPort = 9200;
defaultTransportPort = 9300;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.opensearch";
package = lib.mkOption {
type = lib.types.package;
description = "OpenSearch package to use.";
default = pkgs.opensearch;
example = "pkgs.opensearch";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind for HTTP API.";
default = "127.0.0.1";
};
httpPort = lib.mkOption {
type = lib.types.int;
description = "Port for HTTP API.";
default = defaultHttpPort;
};
transportPort = lib.mkOption {
type = lib.types.int;
description = "Port for transport/cluster communication.";
default = defaultTransportPort;
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Data directory for OpenSearch.";
default = "/var/lib/opensearch";
};
clusterName = lib.mkOption {
type = lib.types.str;
description = "Name of the OpenSearch cluster.";
default = "opensearch";
};
singleNode = lib.mkOption {
type = lib.types.bool;
description = "Run as a single-node cluster (disables bootstrap checks).";
default = true;
};
heapSize = lib.mkOption {
type = lib.types.str;
description = "JVM heap size for OpenSearch (e.g., '512m', '1g').";
default = "512m";
};
extraSettings = lib.mkOption {
type = lib.types.attrs;
description = "Extra settings to add to opensearch.yml.";
default = {};
example = { "action.destructive_requires_name" = true; };
};
};
config = lib.mkIf cfg.enable {
services.opensearch = {
enable = true;
package = cfg.package;
dataDir = cfg.dataDir;
settings = lib.mkMerge [
{
"network.host" = cfg.bindToIp;
"http.port" = cfg.httpPort;
"transport.port" = cfg.transportPort;
"cluster.name" = cfg.clusterName;
}
(lib.mkIf cfg.singleNode {
"discovery.type" = "single-node";
})
cfg.extraSettings
];
extraJavaOptions = [
"-Xms${cfg.heapSize}"
"-Xmx${cfg.heapSize}"
];
};
# Install curl for API access
environment.systemPackages = [ pkgs.curl pkgs.jq ];
# Open firewall for OpenSearch if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.httpPort
cfg.transportPort
];
};
}
@@ -0,0 +1,70 @@
{ config, pkgs, lib, ... }:
let
appName = "postgresql";
appPort = 5432;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.postgresql";
package = lib.mkOption {
type = lib.types.package;
description = "PostgreSQL package to use.";
default = pkgs.postgresql_16;
example = "pkgs.postgresql_15";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
initialDatabases = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of databases to create on initialization.";
default = [];
example = [ "myapp" "testdb" ];
};
authentication = lib.mkOption {
type = lib.types.lines;
description = "pg_hba.conf authentication rules.";
default = ''
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
};
};
config = lib.mkIf cfg.enable {
services.postgresql = {
enable = true;
package = cfg.package;
enableTCPIP = true;
authentication = cfg.authentication;
settings = {
port = lib.mkDefault cfg.bindToPort;
listen_addresses = lib.mkDefault cfg.bindToIp;
};
# Create initial databases if specified
ensureDatabases = cfg.initialDatabases;
};
# Open firewall for PostgreSQL if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,99 @@
{ config, pkgs, lib, ... }:
let
appName = "rabbitmq";
defaultPort = 5672;
defaultManagementPort = 15672;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.rabbitmq";
package = lib.mkOption {
type = lib.types.package;
description = "RabbitMQ package to use.";
default = pkgs.rabbitmq-server;
example = "pkgs.rabbitmq-server";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "AMQP port to bind.";
default = defaultPort;
};
managementPlugin = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable the RabbitMQ management plugin (web UI).";
default = true;
};
port = lib.mkOption {
type = lib.types.int;
description = "Port for the management web UI.";
default = defaultManagementPort;
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional RabbitMQ plugins to enable.";
default = [];
example = [ "rabbitmq_shovel" "rabbitmq_federation" ];
};
configItems = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Additional RabbitMQ configuration items (key-value pairs).";
default = {};
example = {
"vm_memory_high_watermark" = "0.6";
"disk_free_limit.absolute" = "1GB";
};
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall ports for RabbitMQ.";
default = false;
};
};
config = lib.mkIf cfg.enable {
services.rabbitmq = {
enable = true;
package = cfg.package;
listenAddress = cfg.bindToIp;
port = cfg.bindToPort;
# Enable management plugin if requested
managementPlugin.enable = cfg.managementPlugin.enable;
managementPlugin.port = cfg.managementPlugin.port;
# Combine user plugins with management plugin
plugins = cfg.plugins;
# Pass through additional configuration
configItems = cfg.configItems;
};
# Install rabbitmqadmin CLI tool when management plugin is enabled
environment.systemPackages = lib.mkIf cfg.managementPlugin.enable [
pkgs.rabbitmq-server
];
# Open firewall ports if requested
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optional cfg.managementPlugin.enable cfg.managementPlugin.port)
);
};
}
@@ -0,0 +1,134 @@
{ config, pkgs, lib, ... }:
let
appName = "redis";
defaultPort = 6379;
cfg = config.infrastructure.${appName};
# Server options submodule
serverOptions = { name, ... }: {
options = {
enable = lib.mkEnableOption "this Redis server instance" // { default = true; };
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = defaultPort;
};
maxMemory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Maximum memory Redis can use (e.g., '256mb', '1gb'). Null for unlimited.";
default = null;
example = "256mb";
};
maxMemoryPolicy = lib.mkOption {
type = lib.types.enum [ "noeviction" "allkeys-lru" "volatile-lru" "allkeys-random" "volatile-random" "volatile-ttl" ];
description = "Policy for handling keys when maxMemory is reached.";
default = "noeviction";
};
requirePass = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Password for Redis authentication. Null for no authentication.";
default = null;
};
databases = lib.mkOption {
type = lib.types.int;
description = "Number of databases to configure.";
default = 16;
};
appendOnly = lib.mkOption {
type = lib.types.bool;
description = "Enable append-only file persistence.";
default = false;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for this Redis instance.";
default = false;
};
};
};
# Filter enabled servers
enabledServers = lib.filterAttrs (name: serverCfg: serverCfg.enable) cfg.servers;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.redis";
package = lib.mkOption {
type = lib.types.package;
description = "Redis package to use.";
default = pkgs.redis;
example = "pkgs.redis";
};
servers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule serverOptions);
description = ''
Named Redis server instances.
Each server creates a systemd service named redis-<name>.service.
Use an empty string "" for the default server (redis.service).
'';
default = {};
example = lib.literalExpression ''
{
"" = {
bindToPort = 6379;
};
nextcloud = {
bindToPort = 6380;
maxMemory = "256mb";
};
cache = {
bindToPort = 6381;
maxMemory = "512mb";
maxMemoryPolicy = "allkeys-lru";
};
}
'';
};
};
config = lib.mkIf cfg.enable {
# Set the package at the top level
services.redis.package = cfg.package;
# Create each enabled server
services.redis.servers = lib.mapAttrs (name: serverCfg: {
enable = true;
bind = serverCfg.bindToIp;
port = serverCfg.bindToPort;
databases = serverCfg.databases;
appendOnly = serverCfg.appendOnly;
requirePass = serverCfg.requirePass;
settings = lib.mkMerge [
(lib.mkIf (serverCfg.maxMemory != null) {
maxmemory = serverCfg.maxMemory;
maxmemory-policy = serverCfg.maxMemoryPolicy;
})
];
}) enabledServers;
# Install redis-cli for CLI access
environment.systemPackages = [ cfg.package ];
# Open firewall for servers that request it
networking.firewall.allowedTCPPorts = lib.pipe enabledServers [
(lib.filterAttrs (name: serverCfg: serverCfg.openFirewall))
(lib.mapAttrsToList (name: serverCfg: serverCfg.bindToPort))
];
};
}