mirror of
https://github.com/rubenhensen/k8scd.git
synced 2026-09-17 02:12:55 +02:00
Add nix-infra-machine
This commit is contained in:
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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))
|
||||
];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user