Add nix-infra-machine

This commit is contained in:
Ruben Hensen
2026-03-15 18:31:53 +01:00
parent 28ea6f4a47
commit 1034986fa1
79 changed files with 14465 additions and 0 deletions
+3
View File
@@ -3,3 +3,6 @@ repomix-output.txt
credentials-velero
velero-credentials
.env
nix-infra-machine/ssh/
nix-infra-machine/.env
nix-infra-machine/nix/
+15
View File
@@ -0,0 +1,15 @@
# NOTE: This file contains secrets are required for various operations
# by the nix-infra CLI. Make sure the file is encrypted when not in use
# The Hetzner Cloud API-token is needed to perform provisioning
# and discovery https://www.hetzner.com/cloud/
HCLOUD_TOKEN=
# SSH
SSH_KEY=
SSH_EMAIL=
# Secrets
# Password used to encrypt secrets at rest
SECRETS_PWD=
+8
View File
@@ -0,0 +1,8 @@
# Env variables, some are probably secret
.env
# These are the application configuration secrets passed to systemd credentials
secrets/*
# This is the certificate authority
ca/*
# This is the ssh directory
ssh/*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Sebastian Ware
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+241
View File
@@ -0,0 +1,241 @@
# nix-infra-machine
A standalone machine template for [nix-infra](https://github.com/jhsware/nix-infra). This template allows you to deploy and manage individual machines (or fleets of machines) with minimal configuration. All you need is a Hetzner Cloud account.
## Prerequisites
- [nix-infra CLI](https://github.com/jhsware/nix-infra/releases) installed
- A Hetzner Cloud account with an API token
- Git installed
Optional but recommended: Install [Nix](https://docs.determinate.systems/determinate-nix/) and work in a nix-shell for reproducible environments.
## Quick Start
1. Run this script to clone the template:
```sh
sh <(curl -L https://raw.githubusercontent.com/jhsware/nix-infra-machine/refs/heads/main/scripts/get-test.sh)
```
2. Get an API token from your Hetzner Cloud project
3. Edit the `.env` file in the created folder with your token and settings
4. Explore available commands:
```sh
cd test-nix-infra-machine
# Infrastructure management (create, destroy, ssh, etc.)
./cli --help
# Run test suite against machines
./__test__/run-tests.sh --help
```
## CLI Commands
The `cli` script is your main interface for managing infrastructure:
```sh
# Create a machine
./cli create node001
# Create multiple machines
./cli create node001 node002 node003
# SSH into a machine
./cli ssh node001
# Run commands on machines
./cli cmd --target=node001 "systemctl status nginx"
# Update configuration and deploy apps
./cli update node001
# Upgrade NixOS version
./cli upgrade node001
# Rollback to previous configuration
./cli rollback node001
# Run app module actions
./cli action --target=node001 myapp status
# Port forward from remote to local
./cli port-forward --target=node001 --port-mapping=8080:80
# Destroy machines
./cli destroy --target="node001 node002"
# Launch Claude with MCP integration
./cli claude
```
## Running Tests
The test workflow has two stages:
### 1. Create the test machines
The `create` command provisions the base machines and verifies basic functionality:
```sh
# Provision machines and run basic health checks
./__test__/run-tests.sh create
```
This creates and verifies: NixOS installation and basic system health.
### 2. Run app_module tests against the machines
Once you have running machines, use `run` to test specific app_modules:
```sh
# Run a single app test (e.g., mongodb)
./__test__/run-tests.sh run mongodb
# Keep test apps deployed after running
./__test__/run-tests.sh run --no-teardown mongodb
```
Available tests are defined in `__test__/<test-name>/test.sh`. List available tests:
```sh
ls __test__/*/test.sh
```
### Other test commands
```sh
# Reset machine state between test runs
./__test__/run-tests.sh reset mongodb
# Destroy all test machines
./__test__/run-tests.sh destroy
# Check machine health
./__test__/run-tests.sh test
```
Useful commands for exploring running test machines:
```sh
./__test__/run-tests.sh ssh node001
./__test__/run-tests.sh cmd --target=node001 "uptime"
```
### Developing App Modules with Claude
1. Install nix-infra, including nix-infra-dev-mcp
https://github.com/jhsware/nix-infra
2. Run claude with access to nix-infra-dev-mcp:
```sh
./__test__/run-tests.sh claude-dev
```
3. Create a project and set instructions to:
```
Important! Only use tools from nix-infra Development Tools when reading or editing files.
You are an expert dev-ops engineer building nix-infra app modules for single machine deployment. You use Bash to write scripts and Nix to configure NixOS.
The project is in /Users/jhsware/DEV/TEST_INFRA_MACHINE you only edit files in /Users/jhsware/DEV/TEST_INFRA_MACHINE/app_modules and /Users/jhsware/DEV/TEST_INFRA_MACHINE/__test__
Example of an app module can be found at /Users/jhsware/DEV/TEST_INFRA_MACHINE/app_modules/mongodb with tests at /Users/jhsware/DEV/TEST_INFRA_MACHINE/__test__/mongodb
./app_modules/postgresql, ./__test__/postgresql
./app_modules/nextcloud, ./__test__/nextcloud
./app_modules/_unstable/crowdsec, ./__test__/crowdsec
./app_modules/_unstable/n8n, ./__test__/n9n
You are tasked with creating new app modules according to requirements by user. You will create and edit files in order to achieve this goal.
You will also create a test environment and run the app module test files in that environment. Do not destroy the test environment unless explicitly told to do so by the user.
You will perform actions in clear steps and ask the user for confirmation before each step is implemented. For more complex tasks, perform them in multiple sub steps to avoid sessions to time out or overflow.
```
4. Prompt Claude to create an app module and let it run tests using the run-test.sh cli
If a session stalls or fails to complete you can run the tests manually and paste the results. This can help in complex situations where Claude appears to get stuck or times out.
By having a compact project instruction and limited tool set you get maximum context space for your code and problem specific documentation. Claude will run tests and you are mainly required to coach it to complete the task. You may need to perform some limited manual editing and it is useful to create a new chat at times in order to allow Claude to clear it's context and avoid getting tunnel vision.
## Custom Configuration
To create your own configuration from scratch:
1. Clone this repository:
```sh
git clone git@github.com:jhsware/nix-infra-machine.git my-infrastructure
cd my-infrastructure
```
2. Set up environment:
```sh
cp .env.in .env
nano .env # Add your HCLOUD_TOKEN and other settings
```
3. Create and manage your machines:
```sh
./cli create node001
./cli ssh node001
./cli update node001
```
## Directory Structure
```
.
├── cli # Main CLI for infrastructure management
├── .env # Environment configuration (create from .env.in)
├── nodes/ # Per-node configuration files
├── node_types/ # Node type templates (standalone_machine.nix)
├── app_modules/ # Application module definitions
├── __test__/ # Test scripts and test definitions
└── scripts/ # Utility scripts
```
## Deploying Applications
Each node has its configuration in `nodes/`. Configure what apps to run and their settings here.
Deploy using the `update` command:
```sh
./cli update node001 node002
```
You can specify a custom node module:
```sh
./cli create --node-module=node_types/custom_machine.nix node001
```
## Secrets
Store secrets securely using the nix-infra CLI:
```sh
nix-infra secrets store -d . --secret="my-secret-value" --name="app.secret"
```
Or save action output as a secret:
```sh
./cli action --target=node001 myapp create-credentials --save-as-secret="myapp.credentials"
```
Secrets are encrypted locally and deployed as systemd credentials (automatically encrypted/decrypted on demand).
## Node Types
The default node type is `node_types/standalone_machine.nix`. Create custom node types in `node_types/` for different machine configurations, then reference them with `--node-module`.
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env bash
# Assertion library for nix-infra-machine tests
# Provides reusable assertion functions with consistent output formatting
#
# All assertions follow this pattern:
# assert_* "label" [args...]
# Returns 0 on pass, 1 on fail
# Prints colored pass/fail message
#
# Requires: Colors (GREEN, RED, YELLOW, NC) from shared.sh
# Requires: cmd, cmd_value, cmd_clean functions from shared.sh
# ============================================================================
# Service Assertions
# ============================================================================
# Check if a systemd service is active
# Usage: assert_service_active "$node" "service-name" ["optional label"]
assert_service_active() {
local node="$1"
local service="$2"
local label="${3:-$service}"
local status
status=$(cmd_value "$node" "systemctl is-active $service")
if [[ "$status" == "active" ]]; then
echo -e " ${GREEN}${NC} $label: active [pass]"
return 0
else
echo -e " ${RED}${NC} $label: $status [fail]"
return 1
fi
}
# Check if a oneshot service completed successfully
# Usage: assert_service_completed "$node" "service-name" ["optional label"]
assert_service_completed() {
local node="$1"
local service="$2"
local label="${3:-$service}"
local status
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'inactive'")
if [[ "$status" == "inactive" ]]; then
local exit_status
exit_status=$(cmd_value "$node" "systemctl show -p ExecMainStatus $service | cut -d= -f2")
if [[ "$exit_status" == "0" ]]; then
echo -e " ${GREEN}${NC} $label: completed successfully [pass]"
return 0
else
echo -e " ${RED}${NC} $label: failed (exit status: $exit_status) [fail]"
return 1
fi
else
echo -e " ${GREEN}${NC} $label: $status [pass]"
return 0
fi
}
# Check if service SubState is running
# Usage: assert_service_running "$node" "service-name" ["optional label"]
assert_service_running() {
local node="$1"
local service="$2"
local label="${3:-$service}"
local state
state=$(cmd_value "$node" "systemctl show -p SubState $service --value")
if [[ "$state" == "running" ]]; then
echo -e " ${GREEN}${NC} $label: running [pass]"
return 0
else
echo -e " ${RED}${NC} $label: $state [fail]"
return 1
fi
}
# ============================================================================
# Process Assertions
# ============================================================================
# Check if a process is running (using pgrep pattern)
# Usage: assert_process_running "$node" "pattern" "label"
assert_process_running() {
local node="$1"
local pattern="$2"
local label="$3"
local result
result=$(cmd_clean "$node" "pgrep -a $pattern || echo ''")
if [[ -n "$result" ]]; then
echo -e " ${GREEN}${NC} $label process running [pass]"
return 0
else
echo -e " ${RED}${NC} $label process not running [fail]"
return 1
fi
}
# Check process count meets minimum
# Usage: assert_process_count "$node" "pattern" "min_count" "label"
assert_process_count() {
local node="$1"
local pattern="$2"
local min_count="$3"
local label="$4"
local count
count=$(cmd_value "$node" "pgrep -c $pattern || echo 0")
if [[ "$count" -ge "$min_count" ]]; then
echo -e " ${GREEN}${NC} $count $label processes running [pass]"
return 0
else
echo -e " ${RED}${NC} Expected $min_count $label processes, found $count [fail]"
return 1
fi
}
# ============================================================================
# Port Assertions
# ============================================================================
# Check if a port is listening
# Usage: assert_port_listening "$node" "port" ["label"]
assert_port_listening() {
local node="$1"
local port="$2"
local label="${3:-Port $port}"
local result
result=$(cmd "$node" "ss -tlnp | grep :$port")
if [[ "$result" == *":$port"* ]]; then
echo -e " ${GREEN}${NC} $label is listening [pass]"
return 0
else
echo -e " ${RED}${NC} $label is not listening [fail]"
return 1
fi
}
# ============================================================================
# HTTP Assertions
# ============================================================================
# Check HTTP status code
# Usage: assert_http_status "$node" "url" "expected_codes" ["label"]
# expected_codes can be space-separated: "200 302 303"
assert_http_status() {
local node="$1"
local url="$2"
local expected="$3"
local label="${4:-HTTP $url}"
local code
code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' '$url' 2>/dev/null || echo '000'")
for exp in $expected; do
if [[ "$code" == "$exp" ]]; then
echo -e " ${GREEN}${NC} $label: HTTP $code [pass]"
return 0
fi
done
echo -e " ${RED}${NC} $label: HTTP $code (expected: $expected) [fail]"
return 1
}
# Check HTTP response contains string
# Usage: assert_http_contains "$node" "url" "expected_string" ["label"]
assert_http_contains() {
local node="$1"
local url="$2"
local expected="$3"
local label="${4:-HTTP $url}"
local response
response=$(cmd_clean "$node" "curl -s '$url' 2>/dev/null")
if [[ "$response" == *"$expected"* ]]; then
echo -e " ${GREEN}${NC} $label contains '$expected' [pass]"
return 0
else
echo -e " ${RED}${NC} $label missing '$expected' [fail]"
return 1
fi
}
# Check HTTP response contains multiple strings (all must match)
# Usage: assert_http_contains_all "$node" "url" "string1" "string2" ... ["--label" "label"]
assert_http_contains_all() {
local node="$1"
local url="$2"
shift 2
local label="HTTP $url"
local patterns=()
# Parse arguments - check for --label flag
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--label" ]]; then
label="$2"
shift 2
else
patterns+=("$1")
shift
fi
done
local response
response=$(cmd_clean "$node" "curl -s '$url' 2>/dev/null")
for pattern in "${patterns[@]}"; do
if [[ "$response" != *"$pattern"* ]]; then
echo -e " ${RED}${NC} $label missing '$pattern' [fail]"
return 1
fi
done
echo -e " ${GREEN}${NC} $label [pass]"
return 0
}
# ============================================================================
# String Assertions
# ============================================================================
# Check if value equals expected
# Usage: assert_equals "actual" "expected" "label"
assert_equals() {
local actual="$1"
local expected="$2"
local label="$3"
if [[ "$actual" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${RED}${NC} $label: got '$actual', expected '$expected' [fail]"
return 1
fi
}
# Check if value contains substring
# Usage: assert_contains "haystack" "needle" "label"
assert_contains() {
local haystack="$1"
local needle="$2"
local label="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${RED}${NC} $label: missing '$needle' [fail]"
return 1
fi
}
# Check if value contains all substrings
# Usage: assert_contains_all "haystack" "label" "needle1" "needle2" ...
assert_contains_all() {
local haystack="$1"
local label="$2"
shift 2
for needle in "$@"; do
if [[ "$haystack" != *"$needle"* ]]; then
echo -e " ${RED}${NC} $label: missing '$needle' [fail]"
return 1
fi
done
echo -e " ${GREEN}${NC} $label [pass]"
return 0
}
# Check if value is not empty
# Usage: assert_not_empty "value" "label"
assert_not_empty() {
local value="$1"
local label="$2"
if [[ -n "$value" ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${RED}${NC} $label: empty [fail]"
return 1
fi
}
# Check if value is empty or nil (for Redis-style responses)
# Usage: assert_empty_or_nil "value" "label"
assert_empty_or_nil() {
local value="$1"
local label="$2"
if [[ -z "$value" ]] || [[ "$value" == "nil" ]] || [[ "$value" == "(nil)" ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${RED}${NC} $label: got '$value', expected empty/nil [fail]"
return 1
fi
}
# Check if value does NOT contain error indicators
# Usage: assert_no_error "value" "label"
assert_no_error() {
local value="$1"
local label="$2"
if [[ "$value" != *"ERROR"* ]] && [[ "$value" != *"error"* ]] && [[ "$value" != *"Error"* ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${RED}${NC} $label: error found [fail]"
return 1
fi
}
# ============================================================================
# File System Assertions
# ============================================================================
# Check if file exists on remote node
# Usage: assert_file_exists "$node" "/path/to/file" ["label"]
assert_file_exists() {
local node="$1"
local path="$2"
local label="${3:-File $path}"
local result
result=$(cmd_value "$node" "test -f '$path' && echo 'exists' || echo 'missing'")
if [[ "$result" == "exists" ]]; then
echo -e " ${GREEN}${NC} $label exists [pass]"
return 0
else
echo -e " ${RED}${NC} $label not found [fail]"
return 1
fi
}
# Check if directory exists on remote node
# Usage: assert_dir_exists "$node" "/path/to/dir" ["label"]
assert_dir_exists() {
local node="$1"
local path="$2"
local label="${3:-Directory $path}"
local result
result=$(cmd_value "$node" "test -d '$path' && echo 'exists' || echo 'missing'")
if [[ "$result" == "exists" ]]; then
echo -e " ${GREEN}${NC} $label exists [pass]"
return 0
else
echo -e " ${RED}${NC} $label not found [fail]"
return 1
fi
}
# ============================================================================
# Container Assertions
# ============================================================================
# Check if a podman container is running
# Usage: assert_container_running "$node" "container-name" ["label"]
assert_container_running() {
local node="$1"
local container="$2"
local label="${3:-Container $container}"
local status
status=$(cmd_clean "$node" "podman ps --filter name=$container --format '{{.Names}} {{.Status}}'")
if [[ "$status" == *"$container"* ]]; then
echo -e " ${GREEN}${NC} $label running [pass]"
return 0
else
echo -e " ${RED}${NC} $label not running [fail]"
return 1
fi
}
# ============================================================================
# Comparison Assertions
# ============================================================================
# Check if numeric value is greater than or equal to expected
# Usage: assert_gte "actual" "expected" "label"
assert_gte() {
local actual="$1"
local expected="$2"
local label="$3"
if [[ "$actual" -ge "$expected" ]]; then
echo -e " ${GREEN}${NC} $label ($actual >= $expected) [pass]"
return 0
else
echo -e " ${RED}${NC} $label: $actual < $expected [fail]"
return 1
fi
}
# Check if numeric value is less than expected (for ordering/timestamps)
# Usage: assert_lt "actual" "expected" "label"
assert_lt() {
local actual="$1"
local expected="$2"
local label="$3"
if [[ -n "$actual" ]] && [[ -n "$expected" ]] && [[ "$actual" -lt "$expected" ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
echo -e " ${YELLOW}!${NC} $label: could not verify [warn]"
return 1
fi
}
# ============================================================================
# Soft Assertions (warnings instead of failures)
# ============================================================================
# Soft assertion that shows warning instead of failure
# Usage: assert_warn "condition_result" "label" "warn_message"
# condition_result should be "true" or "false"
assert_warn() {
local condition="$1"
local label="$2"
local warn_msg="${3:-}"
if [[ "$condition" == "true" ]]; then
echo -e " ${GREEN}${NC} $label [pass]"
return 0
else
if [[ -n "$warn_msg" ]]; then
echo -e " ${YELLOW}!${NC} $label ($warn_msg) [warn]"
else
echo -e " ${YELLOW}!${NC} $label [warn]"
fi
return 0 # Return success for soft assertions
fi
}
# ============================================================================
# Utility: Show logs on failure
# ============================================================================
# Show service logs (call after a failed assertion)
# Usage: show_service_logs "$node" "service-name" [lines]
show_service_logs() {
local node="$1"
local service="$2"
local lines="${3:-50}"
echo ""
echo "Service logs for $service:"
cmd "$node" "journalctl -n $lines -u $service"
}
# Show container logs (call after a failed assertion)
# Usage: show_container_logs "$node" "container-name" [lines]
show_container_logs() {
local node="$1"
local container="$2"
local lines="${3:-50}"
echo ""
echo "Container logs for $container:"
cmd "$node" "podman logs --tail $lines $container"
}
@@ -0,0 +1,266 @@
{ config, pkgs, lib, ... }: {
imports = [
# Import based on file structure on deployed machine
./app_modules/_unstable/beiwe-backend/default.nix
];
# ==========================================================================
# PostgreSQL Database for Beiwe (using infrastructure module)
# ==========================================================================
config.infrastructure.postgresql = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 5432;
# Note: Do NOT use initialDatabases here - beiwe-db-setup.service creates
# the database when database.createLocally = true. Using both causes race conditions.
authentication = ''
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
};
# ==========================================================================
# MinIO for S3-compatible storage (using infrastructure module)
# ==========================================================================
config.infrastructure.minio = {
enable = true;
bindToIp = "127.0.0.1";
apiPort = 9000;
consolePort = 9001;
rootCredentialsSecretName = "minio-credentials";
dataDir = [ "/var/lib/minio/data" ];
};
# Create MinIO credentials file
config.systemd.services.minio-create-credentials = {
description = "Create MinIO credentials file";
wantedBy = [ "multi-user.target" ];
before = [ "minio.service" ];
requiredBy = [ "minio.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /run/secrets
cat > /run/secrets/minio-credentials <<EOF
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123
EOF
chmod 400 /run/secrets/minio-credentials
'';
};
# Create the beiwe-data bucket after MinIO starts
config.systemd.services.minio-create-bucket = {
description = "Create Beiwe S3 bucket in MinIO";
wantedBy = [ "multi-user.target" ];
after = [ "minio.service" ];
requires = [ "minio.service" ];
before = [ "beiwe-backend.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Set HOME so mc can store its config
Environment = "HOME=/tmp/minio-bucket-setup";
};
path = [ pkgs.minio-client pkgs.curl ];
script = ''
# Create temp home for mc config
mkdir -p /tmp/minio-bucket-setup
export HOME=/tmp/minio-bucket-setup
# Wait for MinIO to be ready (both health check AND API responding)
echo "Waiting for MinIO to be ready..."
for i in {1..60}; do
if curl -sf http://127.0.0.1:9000/minio/health/live > /dev/null 2>&1; then
# Also check that the API is responding
if curl -sf http://127.0.0.1:9000/minio/health/ready > /dev/null 2>&1; then
echo "MinIO is ready"
break
fi
fi
echo "Waiting... attempt $i/60"
sleep 1
done
# Give MinIO a moment to fully initialize
sleep 2
# Configure mc client with explicit alias
echo "Configuring mc client..."
mc alias set local http://127.0.0.1:9000 minioadmin minioadmin123 --api S3v4
# List existing buckets for debugging
echo "Existing buckets:"
mc ls local/ || echo "(no buckets yet)"
# Create bucket if it doesn't exist
echo "Creating beiwe-data bucket..."
mc mb local/beiwe-data --ignore-existing || true
# Verify bucket was created
echo "Verifying bucket creation..."
mc ls local/beiwe-data
echo "Bucket setup complete"
'';
};
# ==========================================================================
# RabbitMQ for Celery task queue (using infrastructure module)
# ==========================================================================
config.infrastructure.rabbitmq = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 5672;
managementPlugin = {
enable = true;
port = 15672;
};
};
# ==========================================================================
# Beiwe Backend Configuration
# ==========================================================================
config.infrastructure.beiwe-backend = {
enable = true;
# Network settings
bindToIp = "0.0.0.0";
bindToPort = 8080;
openFirewall = true;
domainName = "localhost:8080";
# Security (test values - DO NOT use in production!)
flaskSecretKey = "test-secret-key-not-for-production-use";
sysadminEmails = "test@localhost";
# Database configuration (local PostgreSQL)
database = {
host = "localhost"; # Use TCP connection instead of socket
port = 5432;
name = "beiwe";
user = "beiwe";
password = "unused_with_trust_auth"; # Required by Beiwe even with trust auth
sslmode = "disable"; # Disable SSL for local development without certificates
createLocally = true;
};
# S3 configuration (local MinIO)
s3 = {
bucket = "beiwe-data";
accessKeyId = "minioadmin";
secretAccessKey = "minioadmin123";
endpoint = "http://127.0.0.1:9000";
};
# Celery configuration (local RabbitMQ)
celery = {
enable = true;
rabbitmq = {
host = "127.0.0.1";
port = 5672;
user = "guest";
password = "guest";
vhost = "";
};
concurrency = 2;
logLevel = "INFO";
};
# Gunicorn settings
gunicorn = {
workers = 2;
threads = 2;
timeout = 120;
};
};
# ==========================================================================
# Service Dependencies
# ==========================================================================
# Ensure beiwe-backend starts after all dependencies
config.systemd.services.beiwe-backend = {
after = [
"postgresql.service"
"minio.service"
"minio-create-bucket.service"
"beiwe-db-setup.service"
"rabbitmq.service"
];
wants = [
"minio-create-bucket.service"
];
};
# Ensure celery worker starts after RabbitMQ is ready
config.systemd.services.beiwe-celery-worker = {
after = [
"rabbitmq.service"
"postgresql.service"
"minio.service"
];
};
# ==========================================================================
# Test utilities
# ==========================================================================
config.environment.systemPackages = with pkgs; [
curl
jq
minio-client
postgresql
];
}
# ==========================================================================
# NOTES ON SERVICES
# ==========================================================================
#
# REQUIRED SERVICES (all configured):
#
# 1. PostgreSQL (Database)
# - Status: CONFIGURED via infrastructure.postgresql
# - Purpose: Stores all application data, user accounts, study configurations
#
# 2. MinIO/S3 (Object Storage)
# - Status: CONFIGURED via infrastructure.minio
# - Purpose: Stores uploaded data files from mobile apps
#
# OPTIONAL SERVICES:
#
# 3. RabbitMQ + Celery (Message Queue)
# - Status: CONFIGURED via infrastructure.rabbitmq + celery options
# - Purpose: Background task processing
# - Enables:
# * Push notifications to mobile apps
# * Data processing pipelines
# * Forest analysis integration
#
# 4. Firebase Credentials
# - Status: N/A (credentials, not a service)
# - Impact: Push notifications to iOS devices won't work
# - To add: Contact Onnela Lab for credentials, configure via environment vars
#
# 5. Sentry Error Tracking
# - Status: N/A (external service)
# - Impact: No centralized error tracking
# - To add: Create Sentry.io account, add DSN to config.infrastructure.beiwe-backend.sentry.dsn
#
# WHAT WORKS WITH THIS CONFIGURATION:
# - Web-based study management portal
# - User authentication and management
# - Study configuration
# - Survey creation and management
# - Participant registration
# - Data uploads from mobile apps (stored in S3/MinIO)
# - Basic API endpoints
# - Background task processing (with Celery enabled)
# - Push notifications (requires Firebase credentials)
# - Data processing pipelines
#
@@ -0,0 +1,404 @@
#!/usr/bin/env bash
# beiwe-backend test for nix-infra-machine
#
# This test:
# 1. Deploys beiwe-backend with PostgreSQL, MinIO, RabbitMQ, and Celery
# 2. Verifies all services are running
# 3. Tests beiwe-backend endpoints and basic functionality
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down beiwe-backend test..."
# Stop services
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop beiwe-celery-beat 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop beiwe-celery-worker 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop beiwe-backend 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop minio 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop postgresql 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop rabbitmq 2>/dev/null || true'
# Clean up data directories
echo " Removing beiwe-backend data directory..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/beiwe-backend'
echo " Removing MinIO data directory..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/minio'
echo " Removing PostgreSQL data directory..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/postgresql'
echo " Removing RabbitMQ data directory..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/rabbitmq'
echo "beiwe-backend teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Beiwe Backend Test"
echo "========================================"
echo ""
echo "This test verifies:"
echo " - PostgreSQL database connectivity"
echo " - MinIO S3-compatible storage"
echo " - RabbitMQ message broker"
echo " - Celery background task worker"
echo " - Beiwe backend web service"
echo ""
# Deploy the beiwe-backend configuration to test nodes
echo "Step 1: Deploying beiwe-backend configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --debug --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" --no-rebuild \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying deployment..."
echo ""
# Wait for services to be ready
for node in $TARGET; do
echo "Waiting for services on $node..."
# Wait for PostgreSQL
wait_for_service "$node" "postgresql" --timeout=60
wait_for_port "$node" "5432" --timeout=30
# Wait for MinIO
wait_for_service "$node" "minio" --timeout=60
wait_for_port "$node" "9000" --timeout=30
# Wait for MinIO bucket creation to complete
wait_for_service "$node" "minio-create-bucket" --timeout=60
# Wait for RabbitMQ
wait_for_service "$node" "rabbitmq" --timeout=60
wait_for_port "$node" "5672" --timeout=30
# Wait for beiwe-backend (may take time for migrations)
wait_for_service "$node" "beiwe-backend" --timeout=60
wait_for_port "$node" "8080" --timeout=30
# Wait for Celery worker (may take time to connect to RabbitMQ)
wait_for_service "$node" "beiwe-celery-worker" --timeout=90
# Wait for HTTP response (Django may take time to initialize)
wait_for_http "$node" "http://localhost:8080/" "200 302 303 400 403 404 500" --timeout=90
done
# ============================================================================
# Check Service Status
# ============================================================================
echo ""
echo "Step 4: Checking systemd services status..."
echo ""
for node in $TARGET; do
echo "...checking services on $node"
# PostgreSQL
echo " Checking PostgreSQL..."
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 50
# MinIO
echo " Checking MinIO..."
assert_service_active "$node" "minio" || show_service_logs "$node" "minio" 50
# MinIO bucket creation (oneshot service)
echo " Checking minio-create-bucket..."
bucket_setup_status=$(cmd_clean "$node" "systemctl is-active minio-create-bucket 2>/dev/null || echo 'unknown'")
if [[ "$bucket_setup_status" == "active" ]] || [[ "$bucket_setup_status" == "activating" ]]; then
echo -e " ${GREEN}${NC} minio-create-bucket: $bucket_setup_status [pass]"
else
echo -e " ${YELLOW}!${NC} minio-create-bucket: $bucket_setup_status [warn]"
show_service_logs "$node" "minio-create-bucket" 50
fi
# RabbitMQ
echo " Checking RabbitMQ..."
assert_service_active "$node" "rabbitmq" || show_service_logs "$node" "rabbitmq" 50
# Beiwe Backend
echo " Checking beiwe-backend..."
assert_service_active "$node" "beiwe-backend" || show_service_logs "$node" "beiwe-backend" 100
# Celery Worker
echo " Checking beiwe-celery-worker..."
assert_service_active "$node" "beiwe-celery-worker" || show_service_logs "$node" "beiwe-celery-worker" 100
# Celery Beat (scheduler)
echo " Checking beiwe-celery-beat..."
assert_service_active "$node" "beiwe-celery-beat" || show_service_logs "$node" "beiwe-celery-beat" 50
done
# ============================================================================
# Check Port Bindings
# ============================================================================
echo ""
echo "Step 5: Checking port bindings..."
echo ""
for node in $TARGET; do
echo "Checking ports on $node..."
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
assert_port_listening "$node" "9000" "MinIO API port 9000"
assert_port_listening "$node" "5672" "RabbitMQ AMQP port 5672"
assert_port_listening "$node" "15672" "RabbitMQ Management port 15672"
assert_port_listening "$node" "8080" "Beiwe backend port 8080"
done
# ============================================================================
# Database Tests
# ============================================================================
echo ""
echo "Step 6: Testing PostgreSQL database..."
echo ""
for node in $TARGET; do
echo "Testing database on $node..."
# Check database exists
db_exists=$(cmd_clean "$node" "sudo -u postgres psql -lqt | grep -c beiwe || echo 0")
if [[ "$db_exists" -ge 1 ]]; then
echo -e " ${GREEN}${NC} Database 'beiwe' exists [pass]"
else
echo -e " ${RED}${NC} Database 'beiwe' not found [fail]"
fi
# Check user exists
user_exists=$(cmd_clean "$node" "sudo -u postgres psql -c \"SELECT 1 FROM pg_roles WHERE rolname='beiwe'\" | grep -c 1 || echo 0")
if [[ "$user_exists" -ge 1 ]]; then
echo -e " ${GREEN}${NC} User 'beiwe' exists [pass]"
else
echo -e " ${YELLOW}!${NC} User 'beiwe' not found (may be created on first run) [warn]"
fi
done
# ============================================================================
# MinIO Tests
# ============================================================================
echo ""
echo "Step 7: Testing MinIO S3 storage..."
echo ""
for node in $TARGET; do
echo "Testing MinIO on $node..."
# Check MinIO health
minio_health=$(cmd_clean "$node" "curl -s http://127.0.0.1:9000/minio/health/live 2>/dev/null || echo 'failed'")
if [[ "$minio_health" != "failed" ]]; then
echo -e " ${GREEN}${NC} MinIO health check passed [pass]"
else
echo -e " ${RED}${NC} MinIO health check failed [fail]"
fi
# Check bucket exists (set HOME for mc config)
bucket_exists=$(cmd_clean "$node" "export HOME=/tmp/mc-test-check && mkdir -p \$HOME && mc alias set local http://127.0.0.1:9000 minioadmin minioadmin123 --api S3v4 > /dev/null 2>&1 && mc ls local/beiwe-data > /dev/null 2>&1 && echo 'yes' || echo 'no'")
if [[ "$bucket_exists" == "yes" ]]; then
echo -e " ${GREEN}${NC} Bucket 'beiwe-data' exists [pass]"
else
echo -e " ${RED}${NC} Bucket 'beiwe-data' not found [fail]"
# Show bucket list for debugging
echo " Available buckets:"
cmd_clean "$node" "export HOME=/tmp/mc-test-check && mc ls local/ 2>/dev/null || echo ' (none)'" | while read line; do echo " $line"; done
fi
done
# ============================================================================
# RabbitMQ Tests
# ============================================================================
echo ""
echo "Step 8: Testing RabbitMQ message broker..."
echo ""
for node in $TARGET; do
echo "Testing RabbitMQ on $node..."
# Check RabbitMQ via management API (more reliable than rabbitmqctl which needs root)
rabbitmq_api_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' -u guest:guest http://127.0.0.1:15672/api/overview 2>/dev/null || echo '000'")
if [[ "$rabbitmq_api_status" == "200" ]]; then
echo -e " ${GREEN}${NC} RabbitMQ API responding (status: $rabbitmq_api_status) [pass]"
else
echo -e " ${RED}${NC} RabbitMQ API not responding (status: $rabbitmq_api_status) [fail]"
fi
# Check management UI is accessible
mgmt_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:15672/ 2>/dev/null || echo '000'")
if [[ "$mgmt_status" == "200" ]] || [[ "$mgmt_status" == "301" ]]; then
echo -e " ${GREEN}${NC} RabbitMQ Management UI accessible (status: $mgmt_status) [pass]"
else
echo -e " ${YELLOW}!${NC} RabbitMQ Management UI returned status: $mgmt_status [warn]"
fi
done
# ============================================================================
# Celery Worker Tests
# ============================================================================
echo ""
echo "Step 9: Testing Celery worker..."
echo ""
for node in $TARGET; do
echo "Testing Celery on $node..."
# Check Celery process is running
celery_running=$(cmd_clean "$node" "pgrep -f 'celery.*worker' > /dev/null && echo 'yes' || echo 'no'")
if [[ "$celery_running" == "yes" ]]; then
echo -e " ${GREEN}${NC} Celery worker process running [pass]"
else
echo -e " ${RED}${NC} Celery worker process not found [fail]"
fi
# Check Celery beat (scheduler) is running
celery_beat=$(cmd_clean "$node" "pgrep -f 'celery.*beat' > /dev/null && echo 'yes' || echo 'no'")
if [[ "$celery_beat" == "yes" ]]; then
echo -e " ${GREEN}${NC} Celery beat (scheduler) running [pass]"
else
echo -e " ${YELLOW}!${NC} Celery beat not running (scheduled tasks may not work) [warn]"
fi
done
# ============================================================================
# Beiwe Backend HTTP Tests
# ============================================================================
echo ""
echo "Step 10: Testing Beiwe backend HTTP endpoints..."
echo ""
for node in $TARGET; do
echo "Testing Beiwe backend on $node..."
# Test basic HTTP response (any response means server is running)
http_status=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/ 2>/dev/null")
if [[ -n "$http_status" ]] && [[ "$http_status" != "000" ]]; then
echo -e " ${GREEN}${NC} HTTP response received (status: $http_status) [pass]"
else
echo -e " ${RED}${NC} No HTTP response from beiwe-backend [fail]"
fi
# Test if Django is responding (check for specific patterns in response)
response_body=$(cmd_clean "$node" "curl -s http://localhost:8080/ 2>/dev/null | head -c 500")
if [[ "$response_body" == *"html"* ]] || [[ "$response_body" == *"HTML"* ]] || [[ "$response_body" == *"django"* ]] || [[ "$response_body" == *"Django"* ]] || [[ "$response_body" == *"Beiwe"* ]] || [[ "$response_body" == *"beiwe"* ]]; then
echo -e " ${GREEN}${NC} Django/Beiwe response detected [pass]"
else
echo -e " ${YELLOW}!${NC} Response doesn't look like Django/Beiwe (may still be OK) [warn]"
echo " Response preview: ${response_body:0:100}..."
fi
# Check that gunicorn process is running
echo " Checking gunicorn process..."
gunicorn_running=$(cmd_clean "$node" "pgrep -f gunicorn > /dev/null && echo 'yes' || echo 'no'")
if [[ "$gunicorn_running" == "yes" ]]; then
echo -e " ${GREEN}${NC} Gunicorn process running [pass]"
else
echo -e " ${RED}${NC} Gunicorn process not found [fail]"
fi
done
# ============================================================================
# Service Health Summary
# ============================================================================
echo ""
echo "Step 11: Final health checks..."
echo ""
for node in $TARGET; do
echo "Final checks on $node..."
# Check for any failed units
echo " Checking for failed units..."
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -E 'beiwe|minio|postgresql|rabbitmq' || echo 'none'")
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]] || [[ ! "$failed_units" == *"failed"* ]]; then
echo -e " ${GREEN}${NC} No failed related units [pass]"
else
echo -e " ${RED}${NC} Failed units found: $failed_units [fail]"
fi
# Check data directories
echo " Checking data directories..."
assert_dir_exists "$node" "/var/lib/minio" "MinIO data directory"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Beiwe Backend Test Summary"
echo "========================================"
echo ""
echo "Services tested:"
echo " ✓ PostgreSQL (database)"
echo " ✓ MinIO (S3-compatible storage)"
echo " ✓ RabbitMQ (message broker)"
echo " ✓ Celery Worker (background tasks)"
echo " ✓ Celery Beat (task scheduler)"
echo " ✓ Beiwe Backend (Django/Gunicorn)"
echo ""
echo "Optional services NOT configured:"
echo " ✗ Firebase credentials (for push notifications)"
echo " ✗ Sentry error tracking"
echo ""
echo "Features available with this configuration:"
echo " ✓ Web-based study management portal"
echo " ✓ User authentication and management"
echo " ✓ Study and survey configuration"
echo " ✓ Participant registration"
echo " ✓ Data uploads from mobile apps"
echo " ✓ Background task processing"
echo " ✓ Data processing pipelines"
echo ""
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Beiwe Backend Test Complete"
echo "========================================"
@@ -0,0 +1,124 @@
{ config, pkgs, lib, ... }: {
imports = [
# CrowdSec module with modular structure:
# - default.nix: Core engine and detection features
# - bouncers/firewall.nix: Firewall bouncer (nftables/iptables/ipset)
# - bouncers/haproxy.nix: HAProxy SPOA bouncer
# - bouncers/python.nix: Python bouncer registration for pycrowdsec
# - integrations/auditd.nix: Linux Audit Framework integration
# - integrations/console.nix: CrowdSec Console cloud enrollment
./app_modules/_unstable/crowdsec/default.nix
];
# ==========================================================================
# CrowdSec Configuration
# ==========================================================================
infrastructure.crowdsec = {
enable = true;
# --------------------------------------------------------------------------
# Core Configuration (from default.nix)
# --------------------------------------------------------------------------
# API Configuration - Local API (LAPI) settings
api = {
listenAddr = "127.0.0.1";
listenPort = 8080;
};
# Detection Features - What log sources to monitor
features = {
# Enable SSH brute-force protection (monitors journalctl for sshd.service)
sshProtection = true;
# Disable nginx protection (not installed in test environment)
nginxProtection = false;
# Enable system/kernel protection (monitors kernel logs)
systemProtection = true;
# Enable community blocklists (requires console enrollment in production)
communityBlocklists = true;
# --------------------------------------------------------------------------
# Firewall Bouncer (from bouncers/firewall.nix)
# --------------------------------------------------------------------------
# Enable firewall bouncer to block malicious IPs at network level
# Available in NixOS 25.11+ via pkgs.crowdsec-firewall-bouncer
firewallBouncer = true;
# --------------------------------------------------------------------------
# HAProxy Bouncer (from bouncers/haproxy.nix)
# --------------------------------------------------------------------------
# Disable HAProxy protection (no HAProxy service in test environment)
# The module handles missing packages gracefully (defaults to null)
haproxyProtection = false;
};
# --------------------------------------------------------------------------
# Firewall Bouncer Settings (from bouncers/firewall.nix)
# --------------------------------------------------------------------------
bouncer = {
# Use nftables mode with declarative table integration
mode = "nftables";
nftablesIntegration = true;
# Block action and logging
denyAction = "DROP";
denyLog = true;
denyLogPrefix = "crowdsec-test: ";
# Default ban duration
banDuration = "4h";
};
# --------------------------------------------------------------------------
# HAProxy Bouncer Settings (from bouncers/haproxy.nix)
# --------------------------------------------------------------------------
# These settings would apply if haproxyProtection were enabled
# and the cs-haproxy-spoa-bouncer package were available
haproxy = {
listenAddr = "127.0.0.1";
listenPort = 3000;
action = "deny";
logLevel = "info";
};
# --------------------------------------------------------------------------
# Console Integration (from integrations/console.nix)
# --------------------------------------------------------------------------
# Cloud enrollment disabled for test - would need valid enrollment key
console = {
enrollKeyFile = null;
shareDecisions = false;
};
# --------------------------------------------------------------------------
# Auditd Integration (from integrations/auditd.nix)
# --------------------------------------------------------------------------
# Kernel-level security monitoring via Linux Audit Framework
auditd = {
enable = true;
# Custom audit rules for sensitive files
# Note: nixWrappersWhitelistProcess is currently disabled due to
# auditd compatibility issues with the 'comm' field filter
rules = [
"-w /etc/passwd -p wa -k identity"
"-w /etc/shadow -p wa -k identity"
"-w /etc/group -p wa -k identity"
"-w /etc/sudoers -p wa -k sudoers"
];
};
# --------------------------------------------------------------------------
# Python Bouncer (from bouncers/python.nix)
# --------------------------------------------------------------------------
# Disabled for test - enable for Python web application integration
# python = {
# enable = true;
# bouncerName = "my-flask-app";
# apiKeyFileGroup = "www-data";
# };
};
}
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env bash
# CrowdSec Intrusion Prevention System test for nix-infra-machine
#
# This test:
# 1. Deploys CrowdSec with SSH and system protection enabled
# 2. Verifies the CrowdSec service and Local API are running
# 3. Tests firewall bouncer (nftables integration)
# 4. Tests HAProxy SPOA bouncer
# 5. Tests auditd integration for kernel-level monitoring
# 6. Tests cscli functionality (hub, parsers, scenarios)
# 7. Tests basic decision management
# 8. Cleans up on teardown
#
# [NIS2 COMPLIANCE VERIFICATION]
# This test validates that the CrowdSec deployment meets key NIS2 requirements:
# - Article 21(2)(b): Incident handling through automated threat detection
# - Article 21(2)(d): Network security through IDS/IPS capabilities
# - Article 21(2)(g): Security monitoring and logging
# Configuration
CROWDSEC_API_PORT=8080
FIREWALL_BOUNCER_ENABLED=true
HAPROXY_BOUNCER_ENABLED=false
HAPROXY_SPOA_PORT=3000
AUDITD_ENABLED=true
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down CrowdSec test..."
# Stop CrowdSec services
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop crowdsec-haproxy-bouncer 2>/dev/null || true'
fi
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop crowdsec-firewall-bouncer 2>/dev/null || true'
fi
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop crowdsec 2>/dev/null || true'
# Clean up data directories on target nodes
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/crowdsec'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/crowdsec-firewall-bouncer 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/crowdsec-haproxy-bouncer 2>/dev/null || true'
# Clean up declarative configuration directories on target nodes
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /etc/crowdsec 2>/dev/null || true'
echo "CrowdSec teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "CrowdSec Intrusion Prevention Test"
echo "========================================"
echo ""
echo "Testing NIS2-compliant security monitoring setup"
echo ""
# Deploy the CrowdSec configuration to test nodes
echo "Step 1: Deploying CrowdSec configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification - CrowdSec Service
# ============================================================================
echo ""
echo "Step 3: Verifying CrowdSec deployment..."
echo ""
# Wait for CrowdSec service to start
for node in $TARGET; do
wait_for_service "$node" "crowdsec" --timeout=90
wait_for_port "$node" "$CROWDSEC_API_PORT" --timeout=60
done
# Check if the systemd service is active
echo ""
echo "Checking CrowdSec systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "crowdsec" || show_service_logs "$node" "crowdsec" 50
done
# Check if CrowdSec process is running
echo ""
echo "Checking CrowdSec process..."
for node in $TARGET; do
assert_process_running "$node" "crowdsec" "CrowdSec"
done
# Check if CrowdSec API port is listening
echo ""
echo "Checking CrowdSec API port ($CROWDSEC_API_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$CROWDSEC_API_PORT" "CrowdSec API port $CROWDSEC_API_PORT"
done
# ============================================================================
# Test Verification - Firewall Bouncer
# ============================================================================
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
echo ""
echo "Step 4: Verifying Firewall Bouncer..."
echo ""
# Wait for bouncer service
for node in $TARGET; do
wait_for_service "$node" "crowdsec-firewall-bouncer" --timeout=60
done
# Check bouncer service status
echo "Checking Firewall Bouncer systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "crowdsec-firewall-bouncer" || \
show_service_logs "$node" "crowdsec-firewall-bouncer" 50
done
# Check nftables integration
echo ""
echo "Checking nftables integration..."
for node in $TARGET; do
echo " Verifying nftables tables and sets on $node..."
# Check if crowdsec table exists
nft_table=$(cmd_clean "$node" "nft list table ip crowdsec 2>&1 || echo 'not found'")
if [[ "$nft_table" == *"table ip crowdsec"* ]]; then
echo -e " ${GREEN}${NC} CrowdSec nftables IPv4 table exists [pass]"
else
echo -e " ${YELLOW}!${NC} CrowdSec nftables tables: $nft_table [warning]"
fi
# Check if crowdsec set exists in IPv4 table
nft_set=$(cmd_clean "$node" "nft list set ip crowdsec crowdsec-blocklist 2>&1 || echo 'not found'")
if [[ "$nft_set" == *"crowdsec-blocklist"* ]]; then
echo -e " ${GREEN}${NC} CrowdSec IPv4 nftables set exists [pass]"
else
echo -e " ${YELLOW}!${NC} CrowdSec IPv4 set: $nft_set [warning]"
fi
# Check if crowdsec chain exists
nft_chain=$(cmd_clean "$node" "nft list chain ip crowdsec crowdsec-chain 2>&1 || echo 'not found'")
if [[ "$nft_chain" == *"crowdsec-chain"* ]]; then
echo -e " ${GREEN}${NC} CrowdSec nftables chain exists [pass]"
else
echo -e " ${YELLOW}!${NC} CrowdSec chain: $nft_chain [warning]"
fi
# Check IPv6 table
nft_table6=$(cmd_clean "$node" "nft list table ip6 crowdsec6 2>&1 || echo 'not found'")
if [[ "$nft_table6" == *"table ip6 crowdsec6"* ]]; then
echo -e " ${GREEN}${NC} CrowdSec nftables IPv6 table exists [pass]"
else
echo -e " ${YELLOW}!${NC} CrowdSec IPv6 table: $nft_table6 [warning]"
fi
done
else
echo ""
echo "Step 4: Firewall Bouncer (SKIPPED - disabled in test config)"
echo ""
fi
# ============================================================================
# Test Verification - HAProxy SPOA Bouncer
# ============================================================================
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
echo ""
echo "Step 5: Verifying HAProxy SPOA Bouncer..."
echo ""
# Wait for bouncer service
for node in $TARGET; do
wait_for_service "$node" "crowdsec-haproxy-bouncer" --timeout=60
wait_for_port "$node" "$HAPROXY_SPOA_PORT" --timeout=60
done
# Check bouncer service status
echo "Checking HAProxy SPOA Bouncer systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "crowdsec-haproxy-bouncer" || \
show_service_logs "$node" "crowdsec-haproxy-bouncer" 50
done
# Check if SPOA port is listening
echo ""
echo "Checking HAProxy SPOA port ($HAPROXY_SPOA_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$HAPROXY_SPOA_PORT" "HAProxy SPOA port $HAPROXY_SPOA_PORT"
done
# Verify SPOA config exists
echo ""
echo "Checking HAProxy SPOA bouncer configuration..."
for node in $TARGET; do
config_check=$(cmd_clean "$node" "test -f /var/lib/crowdsec-haproxy-bouncer/config.yaml && echo 'exists' || echo 'missing'")
if [[ "$config_check" == *"exists"* ]]; then
echo -e " ${GREEN}${NC} HAProxy SPOA bouncer config exists [pass]"
else
echo -e " ${YELLOW}!${NC} HAProxy SPOA bouncer config: $config_check [warning]"
fi
done
else
echo ""
echo "Step 5: HAProxy SPOA Bouncer (SKIPPED - disabled in test config)"
echo ""
fi
# ============================================================================
# Test Verification - Auditd Integration
# ============================================================================
if [ "$AUDITD_ENABLED" = "true" ]; then
echo ""
echo "Step 6: Verifying Auditd Integration..."
echo ""
# Check if auditd service is running
for node in $TARGET; do
echo "Checking auditd service on $node..."
# Check auditd service status
auditd_status=$(cmd_clean "$node" "systemctl is-active auditd 2>&1 || echo 'inactive'")
if [[ "$auditd_status" == "active" ]]; then
echo -e " ${GREEN}${NC} Auditd service is active [pass]"
else
echo -e " ${YELLOW}!${NC} Auditd service status: $auditd_status [warning]"
fi
# Check audit-rules-nixos service status and errors
echo " Checking audit-rules-nixos service..."
audit_rules_status=$(cmd_clean "$node" "systemctl is-active audit-rules-nixos 2>&1 || echo 'inactive'")
# Service might be one-shot, check if it succeeded
audit_rules_result=$(cmd_clean "$node" "systemctl show audit-rules-nixos --property=Result 2>&1 || echo 'unknown'")
if [[ "$audit_rules_result" == *"success"* ]]; then
echo -e " ${GREEN}${NC} Audit rules loaded successfully [pass]"
else
echo -e " ${YELLOW}!${NC} Audit rules service result: $audit_rules_result [warning]"
# Show the actual error
echo " Debugging audit rules failure..."
audit_rules_file=$(cmd_clean "$node" "find /nix/store -name 'audit.rules' -path '*-audit.rules*' 2>/dev/null | head -1 || echo 'not found'")
echo " Audit rules file: $audit_rules_file"
if [[ "$audit_rules_file" != "not found" ]] && [[ -n "$audit_rules_file" ]]; then
echo " Rules file contents:"
audit_rules_content=$(cmd_clean "$node" "cat '$audit_rules_file' 2>&1 || echo 'cannot read'")
echo "$audit_rules_content" | while read line; do echo " $line"; done
echo " Trying to load rules manually..."
manual_load=$(cmd_clean "$node" "auditctl -R '$audit_rules_file' 2>&1 || echo 'load failed'")
echo " Manual load result: $manual_load"
fi
fi
# Check if audit rules are loaded
echo " Checking loaded audit rules..."
audit_rules=$(cmd_clean "$node" "auditctl -l 2>&1 || echo 'no rules'")
if [[ "$audit_rules" == *"passwd"* ]] || [[ "$audit_rules" == *"shadow"* ]]; then
echo -e " ${GREEN}${NC} Audit rules for identity files loaded [pass]"
else
echo -e " ${YELLOW}!${NC} Audit rules: $audit_rules [warning]"
fi
# Check if sudoers watch rule is loaded
if [[ "$audit_rules" == *"sudoers"* ]]; then
echo -e " ${GREEN}${NC} Audit rule for sudoers file loaded [pass]"
else
echo -e " ${YELLOW}!${NC} Sudoers audit rule not found [warning]"
fi
# Verify whitelist processes are configured (check audit config)
echo " Checking NixOS wrapper whitelist configuration..."
# The whitelist is configured via audit rules, we verify it was processed
# by checking that the service started without errors
if [[ "$auditd_status" == "active" ]]; then
echo -e " ${GREEN}${NC} NixOS wrapper whitelist configured (service active) [pass]"
else
echo -e " ${YELLOW}!${NC} Cannot verify whitelist (auditd not active) [warning]"
fi
done
else
echo ""
echo "Step 6: Auditd Integration (SKIPPED - disabled in test config)"
echo ""
fi
# ============================================================================
# Functional Tests - CLI Tools
# ============================================================================
echo ""
echo "Step 7: Testing CrowdSec CLI (cscli)..."
echo ""
for node in $TARGET; do
echo "Testing cscli on $node..."
# Test cscli version
echo " Checking cscli version..."
version_result=$(cmd_clean "$node" "cscli version 2>&1")
if [[ "$version_result" == *"version"* ]] || [[ "$version_result" == *"crowdsec"* ]]; then
echo -e " ${GREEN}${NC} cscli version command works [pass]"
else
echo -e " ${YELLOW}!${NC} cscli version output: $version_result [warning]"
fi
# Test LAPI status
echo " Checking Local API status..."
lapi_status=$(cmd_clean "$node" "cscli lapi status 2>&1 || true")
if [[ "$lapi_status" == *"successfully interact"* ]] || [[ "$lapi_status" == *"LAPI is reachable"* ]] || [[ "$lapi_status" == *"You can successfully"* ]]; then
echo -e " ${GREEN}${NC} Local API is reachable [pass]"
else
echo -e " ${YELLOW}!${NC} LAPI status check: $lapi_status [warning]"
fi
# Test hub listing
echo " Checking installed hub items..."
hub_result=$(cmd_clean "$node" "cscli hub list 2>&1 || true")
if [[ "$hub_result" == *"COLLECTIONS"* ]] || [[ "$hub_result" == *"PARSERS"* ]] || [[ "$hub_result" == *"SCENARIOS"* ]]; then
echo -e " ${GREEN}${NC} Hub listing works [pass]"
else
echo -e " ${YELLOW}!${NC} Hub listing output: $hub_result [warning]"
fi
# Test collections listing
echo " Checking installed collections..."
collections_result=$(cmd_clean "$node" "cscli collections list 2>&1 || true")
if [[ "$collections_result" == *"sshd"* ]] || [[ "$collections_result" == *"crowdsecurity"* ]]; then
echo -e " ${GREEN}${NC} SSH collection installed [pass]"
else
echo -e " ${YELLOW}!${NC} SSH collection may still be installing [info]"
fi
# Test parsers listing
echo " Checking installed parsers..."
parsers_result=$(cmd_clean "$node" "cscli parsers list 2>&1 || true")
if [[ "$parsers_result" == *"sshd"* ]] || [[ "$parsers_result" == *"syslog"* ]] || [[ "$parsers_result" == *"crowdsecurity"* ]]; then
echo -e " ${GREEN}${NC} Parsers installed [pass]"
else
echo -e " ${YELLOW}!${NC} Parsers may still be installing: $parsers_result [warning]"
fi
# Test scenarios listing
echo " Checking installed scenarios..."
scenarios_result=$(cmd_clean "$node" "cscli scenarios list 2>&1 || true")
if [[ "$scenarios_result" == *"ssh"* ]] || [[ "$scenarios_result" == *"crowdsecurity"* ]]; then
echo -e " ${GREEN}${NC} Scenarios installed [pass]"
else
echo -e " ${YELLOW}!${NC} Scenarios may still be installing [info]"
fi
done
# ============================================================================
# Functional Tests - Decision Management
# ============================================================================
echo ""
echo "Step 8: Testing Decision Management..."
echo ""
for node in $TARGET; do
echo "Testing decision management on $node..."
# List current decisions (should be empty initially)
echo " Listing current decisions..."
decisions_result=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
if [[ "$decisions_result" == *"No active decisions"* ]] || [[ "$decisions_result" == *"0 decision"* ]] || [[ -z "$decisions_result" ]] || [[ "$decisions_result" == *"decision"* ]]; then
echo -e " ${GREEN}${NC} Decision listing works [pass]"
else
echo -e " ${YELLOW}!${NC} Decision listing output: $decisions_result [info]"
fi
# Add a test decision (ban a test IP)
echo " Adding test decision (ban 192.0.2.1 - TEST-NET-1)..."
add_result=$(cmd_clean "$node" "cscli decisions add --ip 192.0.2.1 --reason 'nix-infra test' --type ban 2>&1 || true")
if [[ "$add_result" == *"Decision successfully added"* ]] || [[ "$add_result" == *"added"* ]] || [[ "$add_result" == *"success"* ]]; then
echo -e " ${GREEN}${NC} Decision added successfully [pass]"
else
echo -e " ${YELLOW}!${NC} Decision add result: $add_result [info]"
fi
# Verify the decision was added
echo " Verifying decision was recorded..."
verify_result=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
if [[ "$verify_result" == *"192.0.2.1"* ]]; then
echo -e " ${GREEN}${NC} Decision recorded in database [pass]"
else
echo -e " ${YELLOW}!${NC} Decision verification: $verify_result [warning]"
fi
# Remove the test decision
echo " Removing test decision..."
remove_result=$(cmd_clean "$node" "cscli decisions delete --ip 192.0.2.1 2>&1 || true")
if [[ "$remove_result" == *"decision"* ]] || [[ "$remove_result" == *"deleted"* ]] || [[ "$remove_result" == *"removed"* ]]; then
echo -e " ${GREEN}${NC} Decision delete command executed [pass]"
else
echo -e " ${YELLOW}!${NC} Decision delete result: $remove_result [warning]"
fi
# Verify the decision was actually removed
echo " Verifying decision was removed..."
verify_removed=$(cmd_clean "$node" "cscli decisions list 2>&1 || true")
if [[ "$verify_removed" != *"192.0.2.1"* ]]; then
echo -e " ${GREEN}${NC} Decision successfully removed from database [pass]"
else
echo -e " ${YELLOW}!${NC} Decision may still exist: $verify_removed [warning]"
fi
done
# ============================================================================
# Functional Tests - Bouncer Registration
# ============================================================================
echo ""
echo "Step 9: Testing Bouncer Registration..."
echo ""
for node in $TARGET; do
echo "Checking bouncer status on $node..."
# List registered bouncers
bouncers_result=$(cmd_clean "$node" "cscli bouncers list 2>&1 || true")
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
if [[ "$bouncers_result" == *"firewall"* ]] || [[ "$bouncers_result" == *"bouncer"* ]]; then
echo -e " ${GREEN}${NC} Firewall bouncer is registered [pass]"
else
echo -e " ${YELLOW}!${NC} Bouncer registration status: $bouncers_result [info]"
fi
fi
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
if [[ "$bouncers_result" == *"haproxy"* ]] || [[ "$bouncers_result" == *"spoa"* ]]; then
echo -e " ${GREEN}${NC} HAProxy SPOA bouncer is registered [pass]"
else
echo -e " ${YELLOW}!${NC} HAProxy bouncer registration status: $bouncers_result [info]"
fi
fi
if [ "$FIREWALL_BOUNCER_ENABLED" = "false" ] && [ "$HAPROXY_BOUNCER_ENABLED" = "false" ]; then
echo -e " ${YELLOW}!${NC} Bouncer check skipped (both disabled in config) [info]"
fi
done
# ============================================================================
# Functional Tests - Metrics and API Endpoints
# ============================================================================
echo ""
echo "Step 10: Testing Metrics and API Endpoints..."
echo ""
for node in $TARGET; do
echo "Checking metrics on $node..."
# Test cscli alerts list
echo " Checking cscli alerts functionality..."
alerts_result=$(cmd_clean "$node" "cscli alerts list 2>&1 || true")
if [[ "$alerts_result" == *"No active alerts"* ]] || [[ "$alerts_result" == *"ID"* ]] || [[ "$alerts_result" == *"Source"* ]] || [[ "$alerts_result" == *"Reason"* ]]; then
echo -e " ${GREEN}${NC} cscli alerts command works [pass]"
else
echo -e " ${YELLOW}!${NC} Alerts output unexpected: $alerts_result [warning]"
fi
# Test API endpoint
echo " Checking API endpoint..."
api_result=$(cmd_clean "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$CROWDSEC_API_PORT/v1/decisions 2>&1 || echo '000'")
if [[ "$api_result" == "200" ]] || [[ "$api_result" == "401" ]] || [[ "$api_result" == "403" ]]; then
echo -e " ${GREEN}${NC} API endpoint responds (HTTP $api_result) [pass]"
else
echo -e " ${YELLOW}!${NC} API response code: $api_result [warning]"
fi
done
# ============================================================================
# Functional Tests - Machine Registration
# ============================================================================
echo ""
echo "Step 11: Testing Machine Registration..."
echo ""
for node in $TARGET; do
echo "Checking machine registration on $node..."
echo " Checking registered machines..."
machines_result=$(cmd_clean "$node" "cscli machines list 2>&1 || true")
if [[ "$machines_result" == *"testnode"* ]] || [[ "$machines_result" == *"localhost"* ]] || [[ "$machines_result" == *"validated"* ]]; then
echo -e " ${GREEN}${NC} Local machine is registered with LAPI [pass]"
else
echo -e " ${YELLOW}!${NC} Machine registration status: $machines_result [warning]"
fi
done
# ============================================================================
# Functional Tests - Acquisition Sources
# ============================================================================
echo ""
echo "Step 12: Testing Acquisition Sources..."
echo ""
for node in $TARGET; do
echo "Checking acquisition sources on $node..."
# Check if acquisitions are configured
echo " Checking acquisition configuration..."
acq_file=$(cmd_clean "$node" "cat /var/lib/crowdsec/config/acquisitions.yaml 2>&1 || true")
if [[ "$acq_file" == *"journalctl"* ]] || [[ "$acq_file" == *"sshd"* ]] || [[ "$acq_file" == *"source"* ]]; then
echo -e " ${GREEN}${NC} Acquisition sources configured [pass]"
else
echo -e " ${YELLOW}!${NC} Acquisition config: $acq_file [warning]"
fi
# Check cscli metrics for acquisition stats
echo " Checking acquisition metrics..."
acq_metrics=$(cmd_clean "$node" "cscli metrics show acquisitions 2>&1 || true")
if [[ "$acq_metrics" == *"journalctl"* ]] || [[ "$acq_metrics" == *"file"* ]] || [[ "$acq_metrics" == *"Source"* ]] || [[ "$acq_metrics" == *"Lines"* ]]; then
echo -e " ${GREEN}${NC} Acquisition metrics available [pass]"
else
echo -e " ${YELLOW}!${NC} Acquisition metrics: $acq_metrics [info]"
fi
done
# ============================================================================
# Functional Tests - Database and Config Files
# ============================================================================
echo ""
echo "Step 13: Testing Database and Configuration Files..."
echo ""
for node in $TARGET; do
echo "Checking persistence on $node..."
# Check if SQLite database exists
echo " Checking CrowdSec database..."
db_check=$(cmd_clean "$node" "test -f /var/lib/crowdsec/data/crowdsec.db && echo 'exists' || echo 'missing'")
if [[ "$db_check" == *"exists"* ]]; then
echo -e " ${GREEN}${NC} SQLite database exists [pass]"
else
echo -e " ${RED}${NC} SQLite database missing [fail]"
fi
# Check if config directory exists with required files
echo " Checking configuration files..."
config_check=$(cmd_clean "$node" "ls /var/lib/crowdsec/config/ 2>&1 || true")
if [[ "$config_check" == *"config.yaml"* ]] && [[ "$config_check" == *"profiles.yaml"* ]]; then
echo -e " ${GREEN}${NC} Configuration files present [pass]"
else
echo -e " ${YELLOW}!${NC} Config directory contents: $config_check [warning]"
fi
# Check if hub directory exists
echo " Checking hub directory..."
hub_check=$(cmd_clean "$node" "test -d /var/lib/crowdsec/hub && echo 'exists' || echo 'missing'")
if [[ "$hub_check" == *"exists"* ]]; then
echo -e " ${GREEN}${NC} Hub directory exists [pass]"
else
echo -e " ${YELLOW}!${NC} Hub directory status: $hub_check [warning]"
fi
done
# ============================================================================
# Functional Tests - Service Restart
# ============================================================================
echo ""
echo "Step 14: Testing Service Restart..."
echo ""
for node in $TARGET; do
echo "Testing service restart on $node..."
# Restart the service
echo " Restarting CrowdSec service..."
restart_result=$(cmd_clean "$node" "systemctl restart crowdsec 2>&1 && echo 'restart_ok' || echo 'restart_failed'")
if [[ "$restart_result" == *"restart_ok"* ]]; then
echo -e " ${GREEN}${NC} Service restart command successful [pass]"
else
echo -e " ${RED}${NC} Service restart failed: $restart_result [fail]"
fi
# Wait for service to come back up
echo " Waiting for service to recover..."
wait_for_service "$node" "crowdsec" --timeout=60
wait_for_port "$node" "$CROWDSEC_API_PORT" --timeout=30
# Verify service is active after restart
echo " Verifying service is active after restart..."
assert_service_active "$node" "crowdsec"
# Verify LAPI is responsive after restart
echo " Verifying LAPI responds after restart..."
lapi_after=$(cmd_clean "$node" "cscli lapi status 2>&1 || true")
if [[ "$lapi_after" == *"successfully interact"* ]] || [[ "$lapi_after" == *"You can successfully"* ]]; then
echo -e " ${GREEN}${NC} LAPI responsive after restart [pass]"
else
echo -e " ${YELLOW}!${NC} LAPI status after restart: $lapi_after [warning]"
fi
done
# ============================================================================
# NIS2 Compliance Summary
# ============================================================================
echo ""
echo "========================================"
echo "NIS2 Compliance Verification Summary"
echo "========================================"
echo ""
echo "Article 21(2)(b) - Incident Handling:"
echo " ✓ CrowdSec provides automated threat detection"
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
echo " ✓ Firewall bouncer enables real-time response"
fi
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
echo " ✓ HAProxy SPOA bouncer enables layer 7 protection"
fi
echo ""
echo "Article 21(2)(d) - Network Security:"
echo " ✓ IDS/IPS capabilities via CrowdSec engine"
if [ "$FIREWALL_BOUNCER_ENABLED" = "true" ]; then
echo " ✓ Automated IP blocking via firewall bouncer (nftables)"
fi
if [ "$HAPROXY_BOUNCER_ENABLED" = "true" ]; then
echo " ✓ Application-layer protection via HAProxy SPOA"
fi
echo ""
echo "Article 21(2)(g) - Security Monitoring:"
echo " ✓ SSH authentication monitoring enabled"
echo " ✓ System/kernel log monitoring enabled"
echo " ✓ Centralized decision logging active"
if [ "$AUDITD_ENABLED" = "true" ]; then
echo " ✓ Kernel-level auditd integration enabled"
echo " ✓ NixOS wrapper whitelist configured"
fi
echo ""
echo "Article 21(2)(i) - Human Resources Security:"
echo " ✓ Protection against credential attacks"
echo ""
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "CrowdSec Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "CrowdSec Test Complete"
echo "========================================"
@@ -0,0 +1,12 @@
{ config, pkgs, lib, ... }: {
# Enable Elasticsearch using the infrastructure module
infrastructure.elasticsearch = {
enable = true;
bindToIp = "127.0.0.1";
httpPort = 9202;
transportPort = 9302;
clusterName = "test-cluster";
singleNode = true;
heapSize = "512m";
};
}
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Elasticsearch standalone test for nix-infra-machine
#
# This test:
# 1. Deploys Elasticsearch as a native service on custom port 9202
# 2. Verifies the service is running
# 3. Tests basic Elasticsearch operations (index/query)
# 4. Cleans up on teardown
# Custom ports for testing
ELASTICSEARCH_HTTP_PORT=9202
ELASTICSEARCH_TRANSPORT_PORT=9302
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down Elasticsearch test..."
# Stop Elasticsearch service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop elasticsearch 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/elasticsearch'
echo "Elasticsearch teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Elasticsearch Standalone Test (port $ELASTICSEARCH_HTTP_PORT)"
echo "========================================"
echo ""
# Deploy the elasticsearch configuration to test nodes
echo "Step 1: Deploying Elasticsearch configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying Elasticsearch deployment..."
echo ""
# Wait for Elasticsearch service and API to be ready
for node in $TARGET; do
wait_for_service "$node" "elasticsearch" --timeout=60
wait_for_port "$node" "$ELASTICSEARCH_HTTP_PORT" --timeout=30
wait_for_elasticsearch "$node" "$ELASTICSEARCH_HTTP_PORT" --timeout=60
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "elasticsearch" || show_service_logs "$node" "elasticsearch" 50
done
# Check if Elasticsearch process is running
echo ""
echo "Checking Elasticsearch process..."
for node in $TARGET; do
assert_process_running "$node" "-f elasticsearch" "Elasticsearch"
done
# Check if Elasticsearch HTTP port is listening
echo ""
echo "Checking Elasticsearch HTTP port ($ELASTICSEARCH_HTTP_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$ELASTICSEARCH_HTTP_PORT" "HTTP port $ELASTICSEARCH_HTTP_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test Elasticsearch connection and basic operations
for node in $TARGET; do
echo "Testing Elasticsearch operations on $node..."
# Test cluster health endpoint
echo " Checking cluster health..."
health_result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/_cluster/health")
if assert_contains "$health_result" "cluster_name" "Cluster health endpoint accessible"; then
status=$(echo "$health_result" | jq -r '.status' 2>/dev/null || echo "unknown")
print_info "Cluster status" "$status"
fi
# Create a test index
echo " Creating test index..."
create_result=$(cmd_clean "$node" "curl -s -X PUT 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index' -H 'Content-Type: application/json' -d '{\"settings\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}'")
assert_contains_all "$create_result" "Index creation successful" "acknowledged" "true"
# Insert a test document
echo " Inserting test document..."
insert_result=$(cmd_clean "$node" "curl -s -X POST 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_doc/1' -H 'Content-Type: application/json' -d '{\"name\": \"test\", \"value\": 42}'")
if [[ "$insert_result" == *"created"* ]] || [[ "$insert_result" == *"_id"* ]]; then
echo -e " ${GREEN}${NC} Document insert successful [pass]"
else
echo -e " ${RED}${NC} Document insert failed: $insert_result [fail]"
fi
# Force refresh to make document searchable
cmd "$node" "curl -s -X POST 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_refresh'" > /dev/null 2>&1
# Query the test document
echo " Querying test document..."
query_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_doc/1'")
assert_contains_all "$query_result" "Document query successful" "found" "true"
# Test search functionality
echo " Testing search..."
search_result=$(cmd_clean "$node" "curl -s -X GET 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index/_search' -H 'Content-Type: application/json' -d '{\"query\": {\"match\": {\"name\": \"test\"}}}'")
assert_contains_all "$search_result" "Search operation successful" "hits" "value"
# List indices
echo " Listing indices..."
indices_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/_cat/indices?v'")
assert_contains "$indices_result" "test-index" "Index listing successful"
# Clean up test index
echo " Cleaning up test index..."
cmd "$node" "curl -s -X DELETE 'http://127.0.0.1:$ELASTICSEARCH_HTTP_PORT/test-index'" > /dev/null 2>&1
print_cleanup "Test index cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Elasticsearch Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Elasticsearch Test Complete"
echo "========================================"
@@ -0,0 +1,160 @@
{ config, pkgs, lib, ... }: {
# Enable HAProxy with test configuration including HTTPS
config.infrastructure.haproxy = {
enable = true;
openFirewall = true;
# Enable self-signed certificates for testing HTTPS
selfSigned = {
enable = true;
domains = [ "localhost" "test.local" ];
};
# SSL/TLS settings
ssl = {
minVersion = "TLSv1.2";
hsts = {
enable = true;
maxAge = 31536000;
includeSubDomains = true;
};
};
# Note: ACME/Let's Encrypt cannot be fully tested in VM environment
# as it requires DNS resolution and public internet access.
# For production, set acme.enable = true and configure domains
acme = {
enable = false; # Disabled for testing
acceptTerms = false;
email = "test@example.com";
staging = true; # Use staging server to avoid rate limits
domains = {};
};
# Frontend configurations
frontends = {
# HTTP frontend - handles incoming HTTP traffic
http-in = {
bind = [ "*:80" ];
mode = "http";
options = [ "httplog" ];
acls = [
"is_health path /health"
"is_api path_beg /api"
"is_acme path_beg /.well-known/acme-challenge/"
];
httpRequest = [
"set-header X-Forwarded-Proto http"
];
useBackend = [
"health_backend if is_health"
"api_backend if is_api"
"acme_backend if is_acme"
];
defaultBackend = "web_backend";
};
# HTTPS frontend - handles incoming HTTPS traffic with self-signed cert
https-in = {
bind = [ "*:443 ssl crt /var/lib/haproxy/certs/localhost.pem" ];
mode = "http";
options = [ "httplog" ];
acls = [
"is_health path /health"
"is_api path_beg /api"
];
httpRequest = [
"set-header X-Forwarded-Proto https"
"set-header X-Forwarded-For %[src]"
];
useBackend = [
"health_backend if is_health"
"api_backend if is_api"
];
defaultBackend = "web_backend";
};
};
# Backend configurations
backends = {
# Web backend - serves static content
web_backend = {
mode = "http";
balance = "roundrobin";
options = [ "httpchk GET /" ];
servers = [
"local 127.0.0.1:8080 check"
];
};
# API backend
api_backend = {
mode = "http";
balance = "roundrobin";
options = [ "httpchk GET /api/health" ];
servers = [
"api1 127.0.0.1:8081 check"
];
};
# Health check backend - returns OK for monitoring
health_backend = {
mode = "http";
balance = "roundrobin";
extraConfig = ''
http-request return status 200 content-type text/plain string "OK"
'';
};
# ACME challenge backend (for Let's Encrypt webroot validation)
acme_backend = {
mode = "http";
balance = "roundrobin";
servers = [
"acme 127.0.0.1:8888 check"
];
};
};
# Stats listen section - HAProxy stats page
listen = {
stats = {
bind = [ "*:8404" ];
mode = "http";
extraConfig = ''
stats enable
stats uri /stats
stats refresh 10s
stats admin if LOCALHOST
'';
};
};
};
# Simple test backend server using Python's HTTP server
config.systemd.services.test-backend = {
description = "Test backend server for HAProxy";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8080 --directory /var/www/test";
Restart = "always";
RestartSec = "5s";
};
};
# Create test web content
config.systemd.tmpfiles.rules = [
"d /var/www/test 0755 root root -"
"f /var/www/test/index.html 0644 root root - '<html><body><h1>HAProxy Test Page</h1><p>Backend server is working!</p></body></html>'"
];
# Install utilities for testing
config.environment.systemPackages = with pkgs; [
curl
openssl
python3
];
}
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env bash
# HAProxy test for nix-infra-machine
#
# This test:
# 1. Deploys HAProxy with frontend/backend configuration
# 2. Verifies the service is running
# 3. Tests HTTP endpoints
# 4. Tests HTTPS with self-signed certificates
# 5. Tests load balancing and routing
# 6. Tests stats page
# 7. Tests HSTS headers
# 8. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down HAProxy test..."
# Stop haproxy service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop haproxy 2>/dev/null || true'
# Stop test backend
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop test-backend 2>/dev/null || true'
# Clean up test web content
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/www/test'
# Clean up self-signed certificates
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/haproxy/certs'
echo "HAProxy teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "HAProxy Test"
echo "========================================"
echo ""
# Deploy the haproxy configuration to test nodes
echo "Step 1: Deploying HAProxy configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying HAProxy deployment..."
echo ""
# Wait for services and ports to be ready
for node in $TARGET; do
wait_for_service "$node" "haproxy-generate-self-signed" --timeout=30
wait_for_service "$node" "haproxy" --timeout=30
wait_for_service "$node" "test-backend" --timeout=30
wait_for_port "$node" "80" --timeout=15
wait_for_port "$node" "443" --timeout=15
wait_for_port "$node" "8404" --timeout=15
wait_for_http "$node" "http://127.0.0.1/health" "200" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "haproxy" || show_service_logs "$node" "haproxy" 50
done
# Check if haproxy process is running
echo ""
echo "Checking HAProxy process..."
for node in $TARGET; do
assert_process_running "$node" "haproxy" "HAProxy"
done
# Check if HTTP port is listening
echo ""
echo "Checking HTTP port (80)..."
for node in $TARGET; do
assert_port_listening "$node" "80" "HTTP port 80"
done
# Check if HTTPS port is listening
echo ""
echo "Checking HTTPS port (443)..."
for node in $TARGET; do
assert_port_listening "$node" "443" "HTTPS port 443"
done
# Check if stats port is listening
echo ""
echo "Checking stats port (8404)..."
for node in $TARGET; do
assert_port_listening "$node" "8404" "Stats port 8404"
done
# ============================================================================
# HTTP Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running HTTP functional tests..."
echo ""
for node in $TARGET; do
echo "Testing HAProxy HTTP on $node..."
# Test health endpoint - should return OK from health_backend
echo " Testing health endpoint..."
health_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/health")
assert_contains "$health_response" "OK" "Health endpoint returned OK"
# Test HTTP status code for health
echo " Testing HTTP status codes..."
assert_http_status "$node" "http://127.0.0.1/health" "200" "HTTP 200 OK for health"
# Test default backend - should proxy to test-backend
echo " Testing default backend (web server)..."
web_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/")
if [[ "$web_response" == *"HAProxy Test Page"* ]] || [[ "$web_response" == *"Backend server"* ]]; then
echo -e " ${GREEN}${NC} Default backend routing works [pass]"
else
# Backend might not be ready yet, check for 502/503
web_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo '000'")
if [[ "$web_code" == "502" ]] || [[ "$web_code" == "503" ]]; then
echo -e " ${YELLOW}!${NC} Default backend returned $web_code (backend may be starting) [info]"
else
echo -e " ${GREEN}${NC} Default backend returned HTTP $web_code [pass]"
fi
fi
# Test API backend routing
echo " Testing API backend routing..."
api_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/api/ 2>/dev/null || echo '502'")
if [[ "$api_code" == "502" ]] || [[ "$api_code" == "503" ]]; then
echo -e " ${GREEN}${NC} API backend routing works (502/503 expected - no API backend) [pass]"
else
print_info "API backend routing" "HTTP $api_code"
fi
# Test X-Forwarded-Proto header
echo " Testing X-Forwarded-Proto header..."
echo -e " ${GREEN}${NC} X-Forwarded-Proto header configured [pass]"
done
# ============================================================================
# HTTPS Functional Tests
# ============================================================================
echo ""
echo "Step 5: Running HTTPS functional tests..."
echo ""
for node in $TARGET; do
echo "Testing HAProxy HTTPS on $node..."
# Verify self-signed certificate was generated
echo " Checking self-signed certificate..."
cert_exists=$(cmd_value "$node" "test -f /var/lib/haproxy/certs/localhost.pem && echo 'yes' || echo 'no'")
if [[ "$cert_exists" == "yes" ]]; then
echo -e " ${GREEN}${NC} Self-signed certificate generated [pass]"
else
echo -e " ${RED}${NC} Self-signed certificate not found [fail]"
fi
# Test HTTPS health endpoint (with -k to accept self-signed cert)
echo " Testing HTTPS health endpoint..."
https_health=$(cmd_clean "$node" "curl -sk https://127.0.0.1/health")
assert_contains "$https_health" "OK" "HTTPS health endpoint returned OK"
# Test HTTPS status code
echo " Testing HTTPS status code..."
https_code=$(cmd_value "$node" "curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/health 2>/dev/null || echo '000'")
if [[ "$https_code" == "200" ]]; then
echo -e " ${GREEN}${NC} HTTPS returned HTTP 200 [pass]"
else
echo -e " ${RED}${NC} HTTPS returned HTTP $https_code (expected 200) [fail]"
fi
# Test HTTPS default backend
echo " Testing HTTPS default backend..."
https_web=$(cmd_clean "$node" "curl -sk https://127.0.0.1/")
if [[ "$https_web" == *"HAProxy Test Page"* ]] || [[ "$https_web" == *"Backend server"* ]]; then
echo -e " ${GREEN}${NC} HTTPS default backend routing works [pass]"
else
https_web_code=$(cmd_value "$node" "curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/ 2>/dev/null || echo '000'")
echo -e " ${YELLOW}!${NC} HTTPS default backend returned HTTP $https_web_code [info]"
fi
# Test HSTS header
echo " Testing HSTS header..."
hsts_header=$(cmd_clean "$node" "curl -skI https://127.0.0.1/health | grep -i 'Strict-Transport-Security' || echo 'not-found'")
if [[ "$hsts_header" == *"max-age"* ]]; then
echo -e " ${GREEN}${NC} HSTS header present [pass]"
else
echo -e " ${YELLOW}!${NC} HSTS header not found (may need frontend match) [info]"
fi
# Test SSL certificate info
echo " Testing SSL certificate..."
cert_info=$(cmd_clean "$node" "echo | openssl s_client -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -subject 2>/dev/null || echo 'error'")
if [[ "$cert_info" == *"localhost"* ]] || [[ "$cert_info" == *"CN"* ]]; then
echo -e " ${GREEN}${NC} SSL certificate valid [pass]"
else
echo -e " ${YELLOW}!${NC} Could not verify SSL certificate [info]"
fi
# Test X-Forwarded-Proto is set to https
echo " Testing X-Forwarded-Proto for HTTPS..."
echo -e " ${GREEN}${NC} X-Forwarded-Proto header configured for HTTPS [pass]"
done
# ============================================================================
# Stats and Configuration Tests
# ============================================================================
echo ""
echo "Step 6: Running stats and configuration tests..."
echo ""
for node in $TARGET; do
echo "Testing HAProxy stats on $node..."
# Test HAProxy stats page
echo " Testing HAProxy stats page..."
stats_response=$(cmd_clean "$node" "curl -s http://127.0.0.1:8404/stats")
if [[ "$stats_response" == *"HAProxy"* ]] || [[ "$stats_response" == *"Statistics"* ]] || [[ "$stats_response" == *"haproxy"* ]]; then
echo -e " ${GREEN}${NC} Stats page accessible [pass]"
else
# Check if we at least get a 200 response
stats_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8404/stats 2>/dev/null || echo '000'")
if [[ "$stats_code" == "200" ]]; then
echo -e " ${GREEN}${NC} Stats page returned HTTP 200 [pass]"
else
echo -e " ${RED}${NC} Stats page not accessible (HTTP $stats_code) [fail]"
fi
fi
# Test HAProxy configuration syntax
echo " Testing HAProxy configuration syntax..."
config_test=$(cmd_clean "$node" "haproxy -c -f /etc/haproxy.cfg 2>&1")
if [[ "$config_test" == *"Configuration file is valid"* ]] || [[ "$config_test" == *"valid"* ]] || [[ -z "$config_test" ]]; then
echo -e " ${GREEN}${NC} HAProxy configuration syntax valid [pass]"
else
echo -e " ${YELLOW}!${NC} HAProxy configuration check: $config_test [info]"
fi
# Test ACL routing with path
echo " Testing ACL path routing..."
health_direct=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/health 2>/dev/null")
if [[ "$health_direct" == "200" ]]; then
echo -e " ${GREEN}${NC} ACL path routing for /health works [pass]"
else
echo -e " ${RED}${NC} ACL path routing failed (HTTP $health_direct) [fail]"
fi
# Test that haproxy can handle multiple requests
echo " Testing request handling..."
success_count=0
for i in {1..5}; do
request_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://127.0.0.1/health 2>/dev/null || echo '000'")
if [[ "$request_code" == "200" ]]; then
((success_count++))
fi
done
if [[ $success_count -ge 4 ]]; then
echo -e " ${GREEN}${NC} Request handling works ($success_count/5 successful) [pass]"
else
echo -e " ${YELLOW}!${NC} Request handling: $success_count/5 successful [info]"
fi
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "HAProxy Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "HAProxy Test Complete"
echo "========================================"
@@ -0,0 +1,60 @@
{ config, pkgs, lib, ... }: {
# ==========================================================================
# Home Assistant Configuration (using infrastructure module)
# ==========================================================================
config.infrastructure.home-assistant = {
enable = true;
# Network settings
bindToIp = "0.0.0.0";
bindToPort = 8123;
openFirewall = true;
# Configuration directory
configDir = "/var/lib/hass";
configWritable = true;
# Components for testing
extraComponents = [
# Required for onboarding
"esphome"
"met"
"radio_browser"
];
# Home Assistant configuration
config = {
# Basic setup - includes dependencies for a basic setup
default_config = {};
# Core homeassistant settings
homeassistant = {
name = "Test Home";
unit_system = "metric";
time_zone = "UTC";
};
# HTTP configuration
http = {
server_host = "0.0.0.0";
server_port = 8123;
};
# Enable logging for debugging
logger = {
default = "info";
logs = {
"homeassistant.core" = "debug";
};
};
};
};
# ==========================================================================
# Test utilities
# ==========================================================================
config.environment.systemPackages = with pkgs; [
curl
jq
];
}
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Home Assistant test for nix-infra-machine
#
# This test:
# 1. Deploys Home Assistant with the infrastructure module
# 2. Verifies the service is running
# 3. Tests Home Assistant endpoints and functionality
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down Home Assistant test..."
# Stop services
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop home-assistant 2>/dev/null || true'
# Clean up data directories
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/hass'
echo "Home Assistant teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Home Assistant Test"
echo "========================================"
echo ""
# Deploy the home-assistant configuration to test nodes
echo "Step 1: Deploying Home Assistant configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying Home Assistant deployment..."
echo ""
# Wait for service and HTTP to be ready (Home Assistant can take a while to initialize)
for node in $TARGET; do
wait_for_service "$node" "home-assistant" --timeout=60
wait_for_port "$node" "8123" --timeout=30
wait_for_http "$node" "http://localhost:8123/" "200 302 303" --timeout=90
done
# ============================================================================
# Check Service Status
# ============================================================================
echo ""
echo "Checking systemd services status..."
echo ""
for node in $TARGET; do
echo "Checking services on $node..."
assert_service_active "$node" "home-assistant" || show_service_logs "$node" "home-assistant" 100
done
# ============================================================================
# Check Port Bindings
# ============================================================================
echo ""
echo "Step 4: Checking port bindings..."
echo ""
for node in $TARGET; do
echo "Checking ports on $node..."
assert_port_listening "$node" "8123" "Home Assistant port 8123"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 5: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing Home Assistant on $node..."
# Test Home Assistant HTTP response
echo " Testing Home Assistant HTTP response..."
assert_http_status "$node" "http://localhost:8123/" "200 302 303" "HTTP response"
# Test Home Assistant API health check
echo " Testing Home Assistant API health..."
api_response=$(cmd_clean "$node" "curl -s http://localhost:8123/api/ 2>/dev/null")
if [[ "$api_response" == *"API running"* ]] || [[ "$api_response" == *"message"* ]]; then
echo -e " ${GREEN}${NC} Home Assistant API is responding [pass]"
else
echo -e " ${YELLOW}!${NC} Home Assistant API response: ${api_response:0:100} [warn]"
fi
# Test Home Assistant manifest
echo " Testing Home Assistant manifest endpoint..."
manifest_response=$(cmd_clean "$node" "curl -s http://localhost:8123/manifest.json 2>/dev/null")
assert_contains "$manifest_response" "Home Assistant" "Home Assistant manifest is accessible"
# Test Home Assistant frontend assets
echo " Testing Home Assistant frontend..."
assert_http_status "$node" "http://localhost:8123/frontend_latest/app.js" "200" "Frontend assets accessible"
# Check configuration directory exists
echo " Testing configuration directory..."
assert_dir_exists "$node" "/var/lib/hass" "Configuration directory"
# Check configuration file exists
echo " Testing configuration.yaml..."
config_file=$(cmd_value "$node" "test -f /var/lib/hass/configuration.yaml && echo 'exists' || echo 'missing'")
assert_warn "$([[ "$config_file" == "exists" ]] && echo true || echo false)" "configuration.yaml exists" "may be using NixOS-managed config"
# Check Home Assistant database
echo " Testing Home Assistant database..."
db_exists=$(cmd_value "$node" "test -f /var/lib/hass/home-assistant_v2.db && echo 'exists' || echo 'missing'")
assert_warn "$([[ "$db_exists" == "exists" ]] && echo true || echo false)" "Home Assistant database exists" "may still be initializing"
# Check service is not in error state
echo " Checking service state..."
assert_service_running "$node" "home-assistant" "Service running normally"
# Check for any failed units related to home-assistant
echo " Checking for failed units..."
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -i home || echo 'none'")
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]]; then
echo -e " ${GREEN}${NC} No failed home-assistant related units [pass]"
else
echo -e " ${RED}${NC} Failed units found: $failed_units [fail]"
fi
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Home Assistant Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Home Assistant Test Complete"
echo "========================================"
@@ -0,0 +1,23 @@
{ config, pkgs, lib, ... }: {
# Enable MariaDB using the infrastructure module
infrastructure.mariadb = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 3306;
# Create initial database
initialDatabases = [
{ name = "testdb"; }
];
# Create test user with access to testdb
ensureUsers = [
{
name = "testuser";
ensurePermissions = {
"testdb.*" = "ALL PRIVILEGES";
};
}
];
};
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# MariaDB standalone test for nix-infra-machine
#
# This test:
# 1. Deploys MariaDB as a native service
# 2. Verifies the service is running
# 3. Tests basic MariaDB operations (create table, insert, query)
# 4. Cleans up on teardown
# MariaDB port
MARIADB_PORT=3306
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down MariaDB test..."
# Stop MariaDB service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop mysql 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/mysql'
echo "MariaDB teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "MariaDB Standalone Test (port $MARIADB_PORT)"
echo "========================================"
echo ""
# Deploy the mariadb configuration to test nodes
echo "Step 1: Deploying MariaDB configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying MariaDB deployment..."
echo ""
# Wait for service and port to be ready
for node in $TARGET; do
wait_for_service "$node" "mysql" --timeout=30
wait_for_port "$node" "$MARIADB_PORT" --timeout=15
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "mysql" || show_service_logs "$node" "mysql" 30
done
# Check if MariaDB process is running
echo ""
echo "Checking MariaDB process..."
for node in $TARGET; do
process_status=$(cmd_clean "$node" "pgrep -a mariadbd || pgrep -a mysqld")
assert_not_empty "$process_status" "MariaDB process running"
done
# Check if MariaDB port is listening
echo ""
echo "Checking MariaDB port ($MARIADB_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$MARIADB_PORT" "MariaDB port $MARIADB_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test MariaDB connection and basic operations
for node in $TARGET; do
echo "Testing MariaDB operations on $node..."
# Test connection
echo " Testing connection..."
conn_result=$(cmd_clean "$node" "mysql -u root -e 'SELECT 1 as test;' 2>&1")
assert_contains "$conn_result" "1" "Connection successful"
# Check if testdb was created
echo " Checking testdb database..."
db_check=$(cmd_clean "$node" "mysql -u root -e 'SHOW DATABASES;' | grep testdb")
assert_contains "$db_check" "testdb" "Database 'testdb' exists"
# Create a test table
echo " Creating test table..."
create_result=$(cmd_clean "$node" "mysql -u root -D testdb -e 'CREATE TABLE IF NOT EXISTS test_table (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100), value INT);' 2>&1")
assert_no_error "$create_result" "Create table successful"
# Insert a test record
echo " Inserting test record..."
insert_result=$(cmd_clean "$node" "mysql -u root -D testdb -e \"INSERT INTO test_table (name, value) VALUES ('test', 42);\" 2>&1")
assert_no_error "$insert_result" "Insert operation successful"
# Query the test record
echo " Querying test record..."
query_result=$(cmd_clean "$node" "mysql -u root -D testdb -e \"SELECT * FROM test_table WHERE name = 'test';\" 2>&1")
assert_contains_all "$query_result" "Query operation successful" "test" "42"
# Test database listing
echo " Listing databases..."
db_list=$(cmd_clean "$node" "mysql -u root -e 'SHOW DATABASES;' 2>&1")
assert_contains_all "$db_list" "Database listing successful" "mysql" "information_schema"
# Test user was created
echo " Checking testuser exists..."
user_check=$(cmd_clean "$node" "mysql -u root -e \"SELECT User FROM mysql.user WHERE User='testuser';\" 2>&1")
assert_contains "$user_check" "testuser" "User 'testuser' exists"
# Clean up test data
echo " Cleaning up test data..."
cmd "$node" "mysql -u root -D testdb -e 'DROP TABLE IF EXISTS test_table;'" > /dev/null 2>&1
print_cleanup "Test data cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "MariaDB Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "MariaDB Test Complete"
echo "========================================"
@@ -0,0 +1,24 @@
{ config, pkgs, lib, ... }: {
# Enable MinIO standalone instance using native NixOS service
config.infrastructure.minio = {
enable = true;
bindToIp = "127.0.0.1";
apiPort = 9002;
consolePort = 9003;
dataDir = [ "/var/lib/minio/data" ];
configDir = "/var/lib/minio/config";
rootCredentialsSecretName = "minio-root-credentials";
region = "us-east-1";
browser = true;
};
# Install MinIO client and utilities for testing
config.environment.systemPackages = with pkgs; [
minio-client
curl
jq
];
# Open firewall for MinIO (only if external access needed)
# config.networking.firewall.allowedTCPPorts = [ 9002 9003 ];
}
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# MinIO standalone test for nix-infra-machine
#
# This test:
# 1. Creates MinIO credentials secret on target nodes
# 2. Deploys MinIO as a native service on custom ports 9002/9003
# 3. Verifies the service is running
# 4. Tests basic MinIO operations (bucket/object operations)
# 5. Cleans up on teardown
# Custom ports for testing
MINIO_API_PORT=9002
MINIO_CONSOLE_PORT=9003
MINIO_USER="testadmin"
MINIO_PASSWORD="testpassword123"
MINIO_SECRET_NAME="minio-root-credentials"
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down MinIO test..."
# Stop MinIO service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop minio 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/minio'
# Clean up secrets
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
"rm -f /run/secrets/$MINIO_SECRET_NAME"
echo "MinIO teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "MinIO Standalone Test (API: $MINIO_API_PORT, Console: $MINIO_CONSOLE_PORT)"
echo "========================================"
echo ""
# Create MinIO credentials secret on target nodes
echo "Step 1: Creating MinIO credentials secret on nodes..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
"mkdir -p /run/secrets && cat > /run/secrets/$MINIO_SECRET_NAME << 'EOF'
MINIO_ROOT_USER=$MINIO_USER
MINIO_ROOT_PASSWORD=$MINIO_PASSWORD
EOF"
# Verify secret was created
echo "Verifying secret creation..."
for node in $TARGET; do
secret_check=$(cmd "$node" "cat /run/secrets/$MINIO_SECRET_NAME 2>/dev/null | head -1")
assert_contains "$secret_check" "MINIO_ROOT_USER" "Secret created on $node"
done
# Deploy the minio configuration to test nodes
echo ""
echo "Step 2: Deploying MinIO configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 3: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
# Restart minio to pick up the secret
echo "Restarting MinIO service to pick up secret..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "systemctl restart minio"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 4: Verifying MinIO deployment..."
echo ""
# Wait for service and ports to be ready
for node in $TARGET; do
wait_for_service "$node" "minio" --timeout=30
wait_for_port "$node" "$MINIO_API_PORT" --timeout=15
wait_for_port "$node" "$MINIO_CONSOLE_PORT" --timeout=15
wait_for_http "$node" "http://127.0.0.1:$MINIO_API_PORT/minio/health/live" "200" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "minio" || show_service_logs "$node" "minio" 50
done
# Check if MinIO process is running
echo ""
echo "Checking MinIO process..."
for node in $TARGET; do
assert_process_running "$node" "minio" "MinIO"
done
# Check if MinIO API port is listening
echo ""
echo "Checking MinIO API port ($MINIO_API_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$MINIO_API_PORT" "API port $MINIO_API_PORT"
done
# Check if MinIO Console port is listening
echo ""
echo "Checking MinIO Console port ($MINIO_CONSOLE_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$MINIO_CONSOLE_PORT" "Console port $MINIO_CONSOLE_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 5: Running functional tests..."
echo ""
# Test MinIO connection and basic operations
for node in $TARGET; do
echo "Testing MinIO operations on $node..."
# Configure mc (MinIO client) alias
echo " Configuring MinIO client..."
mc_config=$(cmd_clean "$node" "mc alias set testminio http://127.0.0.1:$MINIO_API_PORT $MINIO_USER $MINIO_PASSWORD 2>&1")
if [[ "$mc_config" == *"successfully"* ]] || [[ "$mc_config" == *"Added"* ]] || [[ -z "$mc_config" ]]; then
echo -e " ${GREEN}${NC} MinIO client configured [pass]"
else
echo -e " ${RED}${NC} MinIO client configuration failed: $mc_config [fail]"
fi
# Test server health endpoint using HTTP status code
echo " Checking server health..."
assert_http_status "$node" "http://127.0.0.1:$MINIO_API_PORT/minio/health/live" "200" "Server health endpoint"
# Create a test bucket
echo " Creating test bucket..."
bucket_result=$(cmd_clean "$node" "mc mb testminio/test-bucket 2>&1")
if [[ "$bucket_result" == *"Bucket created successfully"* ]] || [[ "$bucket_result" == *"created"* ]]; then
echo -e " ${GREEN}${NC} Bucket creation successful [pass]"
else
echo -e " ${RED}${NC} Bucket creation failed: $bucket_result [fail]"
fi
# List buckets
echo " Listing buckets..."
list_result=$(cmd_clean "$node" "mc ls testminio 2>&1")
assert_contains "$list_result" "test-bucket" "Bucket listing successful"
# Upload a test object
echo " Uploading test object..."
cmd "$node" "echo 'Hello MinIO Test!' > /tmp/test-file.txt"
upload_result=$(cmd_clean "$node" "mc cp /tmp/test-file.txt testminio/test-bucket/test-file.txt 2>&1")
if [[ "$upload_result" == *"test-file.txt"* ]] || [[ -z "$upload_result" ]]; then
echo -e " ${GREEN}${NC} Object upload successful [pass]"
else
echo -e " ${RED}${NC} Object upload failed: $upload_result [fail]"
fi
# List objects in bucket
echo " Listing objects in bucket..."
objects_result=$(cmd_clean "$node" "mc ls testminio/test-bucket 2>&1")
assert_contains "$objects_result" "test-file.txt" "Object listing successful"
# Download the test object
echo " Downloading test object..."
cmd "$node" "rm -f /tmp/downloaded-file.txt"
download_result=$(cmd_clean "$node" "mc cp testminio/test-bucket/test-file.txt /tmp/downloaded-file.txt 2>&1")
content_check=$(cmd_clean "$node" "cat /tmp/downloaded-file.txt 2>/dev/null")
assert_contains "$content_check" "Hello MinIO Test!" "Object download successful"
# Get object info/stat
echo " Getting object info..."
stat_result=$(cmd_clean "$node" "mc stat testminio/test-bucket/test-file.txt 2>&1")
if [[ "$stat_result" == *"test-file.txt"* ]] || [[ "$stat_result" == *"Size"* ]]; then
echo -e " ${GREEN}${NC} Object stat successful [pass]"
else
echo -e " ${RED}${NC} Object stat failed: $stat_result [fail]"
fi
# Clean up - remove object
echo " Cleaning up test object..."
cmd "$node" "mc rm testminio/test-bucket/test-file.txt 2>&1" > /dev/null
print_cleanup "Test object removed"
# Clean up - remove bucket
echo " Cleaning up test bucket..."
cmd "$node" "mc rb testminio/test-bucket 2>&1" > /dev/null
print_cleanup "Test bucket removed"
# Clean up temp files
cmd "$node" "rm -f /tmp/test-file.txt /tmp/downloaded-file.txt" > /dev/null 2>&1
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "MinIO Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "MinIO Test Complete"
echo "========================================"
@@ -0,0 +1,15 @@
{ config, pkgs, lib, ... }: {
# Enable podman for container runtime
config.infrastructure.podman.enable = true;
# Enable MongoDB standalone instance (container-based)
config.infrastructure.mongodb-pod = {
enable = true;
# image = "mongo:6"; # Default, or use "mongo:4.4.29-focal" for older version
bindToIp = "127.0.0.1";
bindToPort = 27017;
};
# Open firewall for MongoDB (only if external access needed)
# config.networking.firewall.allowedTCPPorts = [ 27017 ];
}
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# MongoDB standalone test for nix-infra-machine
#
# This test:
# 1. Deploys MongoDB as a podman container
# 2. Verifies the service is running
# 3. Tests basic MongoDB operations (insert/query)
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down MongoDB test..."
# Stop and remove container if running
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop podman-mongodb 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/mongodb-pod'
echo "MongoDB teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "MongoDB Standalone Test (Podman)"
echo "========================================"
echo ""
# Deploy the mongodb configuration to test nodes
echo "Step 1: Deploying MongoDB configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying MongoDB deployment..."
echo ""
# Wait for service and container to be ready
for node in $TARGET; do
wait_for_service "$node" "podman-mongodb" --timeout=30
wait_for_container "$node" "mongodb" --timeout=30
wait_for_port "$node" "27017" --timeout=15
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "podman-mongodb" || show_service_logs "$node" "podman-mongodb" 30
done
# Check if container is running
echo ""
echo "Checking container status..."
for node in $TARGET; do
assert_container_running "$node" "mongodb" "MongoDB container"
done
# Check if MongoDB port is listening
echo ""
echo "Checking MongoDB port (27017)..."
for node in $TARGET; do
assert_port_listening "$node" "27017" "MongoDB port 27017"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Detect which mongo shell is available (mongosh for 5+, mongo for 4.x)
get_mongo_shell() {
local node=$1
if cmd "$node" "podman exec mongodb which mongosh" > /dev/null 2>&1; then
echo "mongosh"
else
echo "mongo"
fi
}
# Test MongoDB connection and basic operations
for node in $TARGET; do
echo "Testing MongoDB operations on $node..."
# Detect shell
MONGO_SHELL=$(get_mongo_shell "$node")
print_info "Using shell" "$MONGO_SHELL"
# Insert a test document
echo " Inserting test document..."
insert_result=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.insertOne({name: \"test\", value: 42})'")
if [[ "$insert_result" == *"acknowledged"* ]] || [[ "$insert_result" == *"insertedId"* ]]; then
echo -e " ${GREEN}${NC} Insert operation successful [pass]"
else
echo -e " ${RED}${NC} Insert operation failed: $insert_result [fail]"
fi
# Query the test document
echo " Querying test document..."
query_result=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.findOne({name: \"test\"})'")
assert_contains_all "$query_result" "Query operation successful" "value" "42"
# Test database listing
echo " Listing databases..."
db_list=$(cmd_clean "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name)'")
assert_contains "$db_list" "admin" "Database listing successful"
# Clean up test data
echo " Cleaning up test data..."
cmd "$node" "podman exec mongodb $MONGO_SHELL --quiet --eval 'db.test.drop()'" > /dev/null 2>&1
print_cleanup "Test data cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "MongoDB Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "MongoDB Test Complete"
echo "========================================"
@@ -0,0 +1,13 @@
{ config, pkgs, lib, ... }: {
# Allow insecure MongoDB package (CVE-2025-14847)
nixpkgs.config.permittedInsecurePackages = [
"mongodb-ce-8.0.4"
];
# Enable MongoDB using the infrastructure module
infrastructure.mongodb = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 27018;
};
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# MongoDB standalone test for nix-infra-machine
#
# This test:
# 1. Deploys MongoDB as a native service on custom port 27018
# 2. Verifies the service is running
# 3. Tests basic MongoDB operations (insert/query)
# 4. Cleans up on teardown
# Custom port for testing
MONGODB_PORT=27018
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down MongoDB test..."
# Stop MongoDB service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop mongodb 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/mongodb'
echo "MongoDB teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "MongoDB Standalone Test (port $MONGODB_PORT)"
echo "========================================"
echo ""
# Deploy the mongodb configuration to test nodes
echo "Step 1: Deploying MongoDB configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying MongoDB deployment..."
echo ""
# Wait for service and database to be ready
for node in $TARGET; do
wait_for_service "$node" "mongodb" --timeout=30
wait_for_port "$node" "$MONGODB_PORT" --timeout=15
wait_for_mongodb "$node" "$MONGODB_PORT" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "mongodb" || show_service_logs "$node" "mongodb" 30
done
# Check if MongoDB process is running
echo ""
echo "Checking MongoDB process..."
for node in $TARGET; do
assert_process_running "$node" "mongod" "MongoDB"
done
# Check if MongoDB port is listening
echo ""
echo "Checking MongoDB port ($MONGODB_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$MONGODB_PORT" "MongoDB port $MONGODB_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test MongoDB connection and basic operations
for node in $TARGET; do
echo "Testing MongoDB operations on $node..."
# Insert a test document
echo " Inserting test document..."
insert_result=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.insertOne({name: \"test\", value: 42})'")
if [[ "$insert_result" == *"acknowledged"* ]] || [[ "$insert_result" == *"insertedId"* ]]; then
echo -e " ${GREEN}${NC} Insert operation successful [pass]"
else
echo -e " ${RED}${NC} Insert operation failed: $insert_result [fail]"
fi
# Query the test document
echo " Querying test document..."
query_result=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.findOne({name: \"test\"})'")
assert_contains_all "$query_result" "Query operation successful" "value" "42"
# Test database listing
echo " Listing databases..."
db_list=$(cmd_clean "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.adminCommand({listDatabases: 1}).databases.map(d => d.name)'")
assert_contains "$db_list" "admin" "Database listing successful"
# Clean up test data
echo " Cleaning up test data..."
cmd "$node" "mongosh --port $MONGODB_PORT --quiet --eval 'db.test.drop()'" > /dev/null 2>&1
print_cleanup "Test data cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "MongoDB Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "MongoDB Test Complete"
echo "========================================"
@@ -0,0 +1,40 @@
{ config, pkgs, lib, ... }: {
# Enable podman for container runtime
config.infrastructure.podman.enable = true;
# Enable n8n as container-based instance
config.infrastructure.n8n-pod = {
enable = true;
# Use official n8n Docker image
# image = "docker.n8n.io/n8nio/n8n:latest"; # Default
# Network settings
bindToIp = "0.0.0.0";
bindToPort = 5678;
openFirewall = true;
# Use SQLite database (default)
database = {
type = "sqlite";
};
# Execution settings
executions = {
pruneData = true;
pruneDataMaxAge = 168; # 7 days for testing
pruneDataMaxCount = 1000;
};
# Additional settings (environment variables)
settings = {
GENERIC_TIMEZONE = "UTC";
};
};
# Test utilities
config.environment.systemPackages = with pkgs; [
curl
jq
];
}
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# n8n-pod test for nix-infra-machine
#
# This test:
# 1. Deploys n8n as a podman container with SQLite backend
# 2. Verifies the service is running
# 3. Tests n8n endpoints and functionality
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down n8n-pod test..."
# Stop and remove container if running
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop podman-n8n-pod 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/n8n-pod'
echo "n8n-pod teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "n8n-pod Test (SQLite, Container)"
echo "========================================"
echo ""
# Deploy the n8n-pod configuration to test nodes
echo "Step 1: Deploying n8n-pod configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying n8n-pod deployment..."
echo ""
# Wait for service, container and HTTP to be ready
for node in $TARGET; do
wait_for_service "$node" "podman-n8n-pod" --timeout=60
wait_for_container "$node" "n8n-pod" --timeout=60
wait_for_port "$node" "5678" --timeout=30
wait_for_http "$node" "http://localhost:5678/" "200 302 303" --timeout=60
done
# ============================================================================
# Check Service Status
# ============================================================================
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "podman-n8n-pod" || show_service_logs "$node" "podman-n8n-pod" 50
done
# Check if container is running
echo ""
echo "Checking container status..."
for node in $TARGET; do
assert_container_running "$node" "n8n-pod" "n8n-pod container"
done
# ============================================================================
# Check Port Bindings
# ============================================================================
echo ""
echo "Step 4: Checking port bindings..."
echo ""
for node in $TARGET; do
echo "Checking ports on $node..."
assert_port_listening "$node" "5678" "n8n port 5678"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 5: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing n8n on $node..."
# Test n8n HTTP response
echo " Testing n8n HTTP response..."
assert_http_status "$node" "http://localhost:5678/" "200 302 303" "HTTP response"
# Test n8n healthcheck endpoint
echo " Testing n8n healthcheck endpoint..."
healthcheck=$(cmd_clean "$node" "curl -s http://localhost:5678/healthz 2>/dev/null")
if [[ "$healthcheck" == *"ok"* ]] || [[ "$healthcheck" == *"healthy"* ]] || [[ -n "$healthcheck" ]]; then
echo -e " ${GREEN}${NC} n8n healthcheck responded: $healthcheck [pass]"
else
echo -e " ${YELLOW}!${NC} n8n healthcheck response: $healthcheck [warn]"
fi
# Test n8n API types endpoint (should list available node types)
echo " Testing n8n API endpoint..."
api_response=$(cmd_clean "$node" "curl -s http://localhost:5678/api/v1/node-types 2>/dev/null | head -c 200")
if [[ "$api_response" == *"data"* ]] || [[ "$api_response" == *"type"* ]]; then
echo -e " ${GREEN}${NC} n8n API is responding [pass]"
else
echo -e " ${YELLOW}!${NC} n8n API response: ${api_response:0:100} [warn]"
fi
# Check n8n data directory exists on host
echo " Testing n8n data directory..."
assert_dir_exists "$node" "/var/lib/n8n-pod" "n8n data directory"
# Check SQLite database file exists (inside container volume)
echo " Testing SQLite database file..."
sqlite_exists=$(cmd_value "$node" "test -f /var/lib/n8n-pod/database.sqlite && echo 'exists' || echo 'missing'")
assert_warn "$([[ "$sqlite_exists" == "exists" ]] && echo true || echo false)" "SQLite database file exists" "may be created on first use"
# Check container logs for errors
echo " Checking container logs for errors..."
error_logs=$(cmd_clean "$node" "podman logs n8n-pod 2>&1 | grep -i 'error\|fatal' | tail -5 || echo 'none'")
if [[ "$error_logs" == *"none"* ]] || [[ -z "$error_logs" ]]; then
echo -e " ${GREEN}${NC} No errors in container logs [pass]"
else
echo -e " ${YELLOW}!${NC} Errors found in logs: $error_logs [warn]"
fi
# Check container health
echo " Checking container process..."
n8n_process=$(cmd_clean "$node" "podman exec n8n-pod pgrep -f 'n8n' || echo 'not_found'")
if [[ "$n8n_process" != "not_found" ]] && [[ -n "$n8n_process" ]]; then
echo -e " ${GREEN}${NC} n8n process is running inside container [pass]"
else
echo -e " ${RED}${NC} n8n process not found in container [fail]"
fi
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "n8n-pod Test Summary (SQLite, Container)"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "n8n-pod Test Complete"
echo "========================================"
@@ -0,0 +1,67 @@
{ config, pkgs, lib, ... }: {
imports = [
# Import based on file structure on deployed machine
./app_modules/_unstable/n8n/default.nix
];
# ==========================================================================
# Swap Configuration (for memory-intensive n8n builds)
# ==========================================================================
config.swapDevices = [{
device = "/swapfile";
size = 4096; # 4GB swap
}];
# ==========================================================================
# Nix Build Settings (limit parallelism to avoid OOM during n8n build)
# ==========================================================================
config.nix.settings = {
# Only one build job at a time
max-jobs = 6;
# Limit cores per build job
cores = 6;
};
# ==========================================================================
# n8n Configuration (using infrastructure module with SQLite)
# ==========================================================================
config.infrastructure.n8n = {
enable = true;
# Reduce build memory to leave room for system (default: 4096)
buildMemoryMB = 8192;
# Network settings
bindToIp = "0.0.0.0";
bindToPort = 5678;
openFirewall = true;
# Use SQLite database (default)
database = {
type = "sqlite";
};
# Execution settings
executions = {
pruneData = true;
pruneDataMaxAge = 168; # 7 days for testing
pruneDataMaxCount = 1000;
};
# Additional settings (environment variables)
settings = {
GENERIC_TIMEZONE = "UTC";
# Enable public API for testing
N8N_PUBLIC_API_ENABLED = "true";
};
};
# ==========================================================================
# Test utilities
# ==========================================================================
config.environment.systemPackages = with pkgs; [
curl
jq
];
}
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bash
# n8n test for nix-infra-machine
#
# This test:
# 1. Deploys n8n with SQLite backend
# 2. Verifies all services are running
# 3. Tests n8n endpoints and REST API functionality
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down n8n test..."
# Stop services
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop n8n 2>/dev/null || true'
# Clean up entire n8n data directory including SQLite database
echo " Removing n8n data directory..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/n8n'
# Clean up temporary cookie file used in tests
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -f /tmp/n8n-cookies.txt 2>/dev/null || true'
echo "n8n teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "n8n Test (SQLite)"
echo "========================================"
echo ""
# Deploy the n8n configuration to test nodes
echo "Step 1: Deploying n8n configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --debug --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" --no-rebuild \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying n8n deployment..."
echo ""
# Wait for service and HTTP to be ready (n8n may take time to initialize)
for node in $TARGET; do
wait_for_service "$node" "n8n" --timeout=60
wait_for_port "$node" "5678" --timeout=30
wait_for_http "$node" "http://localhost:5678/" "200 302 303" --timeout=60
done
# ============================================================================
# Check Service Status
# ============================================================================
echo ""
echo "Checking systemd services status..."
echo ""
for node in $TARGET; do
echo "...checking services on $node"
assert_service_active "$node" "n8n" || show_service_logs "$node" "n8n" 50
done
# ============================================================================
# Check Port Bindings
# ============================================================================
echo ""
echo "Step 4: Checking port bindings..."
echo ""
for node in $TARGET; do
echo "Checking ports on $node..."
assert_port_listening "$node" "5678" "n8n port 5678"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 5: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing n8n on $node..."
# Test n8n HTTP response
echo " Testing n8n HTTP response..."
assert_http_status "$node" "http://localhost:5678/" "200 302 303" "HTTP response"
# Test n8n healthcheck endpoint
echo " Testing n8n healthcheck endpoint..."
healthcheck=$(cmd_clean "$node" "curl -s http://localhost:5678/healthz 2>/dev/null")
if [[ "$healthcheck" == *"ok"* ]] || [[ "$healthcheck" == *"healthy"* ]] || [[ -n "$healthcheck" ]]; then
echo -e " ${GREEN}${NC} n8n healthcheck responded: $healthcheck [pass]"
else
echo -e " ${YELLOW}!${NC} n8n healthcheck response: $healthcheck [warn]"
fi
# ============================================================================
# Authenticated REST API Tests (using session cookie)
# ============================================================================
echo " Setting up authentication for API testing..."
# Create authentication test script using base64 to avoid escaping issues
AUTH_SCRIPT='#!/usr/bin/env bash
# Step 1: Create owner user (will fail if already exists, which is fine)
curl -s -X POST http://localhost:5678/rest/owner/setup \
-H "Content-Type: application/json" \
-d "{\"email\":\"test@example.com\",\"firstName\":\"Test\",\"lastName\":\"User\",\"password\":\"TestPassword123!\"}" > /tmp/n8n-owner-result.json 2>&1
# Step 2: Login and get session cookie
curl -s -c /tmp/n8n-cookies.txt -X POST http://localhost:5678/rest/login \
-H "Content-Type: application/json" \
-d "{\"emailOrLdapLoginId\":\"test@example.com\",\"password\":\"TestPassword123!\"}" > /tmp/n8n-login-result.json 2>&1
# Check login result
if grep -q "test@example.com" /tmp/n8n-login-result.json 2>/dev/null; then
echo "LOGIN_SUCCESS"
# Step 3: Test REST API with session cookie (list workflows)
WORKFLOWS_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt http://localhost:5678/rest/workflows 2>&1)
if echo "$WORKFLOWS_RESPONSE" | jq -e ".data" > /dev/null 2>&1; then
WORKFLOW_COUNT=$(echo "$WORKFLOWS_RESPONSE" | jq -r ".data | length")
echo "REST_API_SUCCESS:workflows=$WORKFLOW_COUNT"
else
echo "REST_API_FAILED:$WORKFLOWS_RESPONSE"
fi
# Step 4: Test creating a workflow via REST API
CREATE_WORKFLOW_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt -X POST http://localhost:5678/rest/workflows \
-H "Content-Type: application/json" \
-d "{\"name\":\"Test Workflow\",\"nodes\":[],\"connections\":{},\"settings\":{},\"active\":false}" 2>&1)
if echo "$CREATE_WORKFLOW_RESPONSE" | jq -e ".data.id" > /dev/null 2>&1; then
WORKFLOW_ID=$(echo "$CREATE_WORKFLOW_RESPONSE" | jq -r ".data.id")
echo "WORKFLOW_CREATED:$WORKFLOW_ID"
# Step 5: Verify the workflow exists (skip delete - teardown will clean up)
# Note: n8n archive and delete API uses internal endpoints that are not stable
VERIFY_WORKFLOW_RESPONSE=$(curl -s -b /tmp/n8n-cookies.txt "http://localhost:5678/rest/workflows/$WORKFLOW_ID" 2>&1)
if echo "$VERIFY_WORKFLOW_RESPONSE" | jq -e ".data.id" > /dev/null 2>&1; then
echo "WORKFLOW_VERIFIED"
else
echo "WORKFLOW_VERIFY_FAILED:$VERIFY_WORKFLOW_RESPONSE"
fi
else
echo "WORKFLOW_CREATE_FAILED:$CREATE_WORKFLOW_RESPONSE"
fi
else
echo "LOGIN_FAILED:$(cat /tmp/n8n-login-result.json)"
fi
# Cleanup
rm -f /tmp/n8n-owner-result.json /tmp/n8n-login-result.json /tmp/n8n-cookies.txt
'
# Encode script and send to remote node
AUTH_SCRIPT_B64=$(echo "$AUTH_SCRIPT" | base64 -w0)
cmd "$node" "echo '$AUTH_SCRIPT_B64' | base64 -d > /tmp/n8n-auth-test.sh && chmod +x /tmp/n8n-auth-test.sh"
# Run the auth test script
auth_result=$(cmd_clean "$node" "bash /tmp/n8n-auth-test.sh")
cmd "$node" "rm -f /tmp/n8n-auth-test.sh"
# Parse results
if [[ "$auth_result" == *"LOGIN_SUCCESS"* ]]; then
echo -e " ${GREEN}${NC} Login successful [pass]"
# Check REST API access with session cookie
if [[ "$auth_result" == *"REST_API_SUCCESS:"* ]]; then
rest_info=$(echo "$auth_result" | grep "REST_API_SUCCESS:" | sed 's/.*REST_API_SUCCESS://')
echo -e " ${GREEN}${NC} REST API accessible ($rest_info) [pass]"
elif [[ "$auth_result" == *"REST_API_FAILED:"* ]]; then
rest_error=$(echo "$auth_result" | grep "REST_API_FAILED:" | sed 's/.*REST_API_FAILED://')
echo -e " ${RED}${NC} REST API failed: ${rest_error:0:100} [fail]"
fi
# Check workflow CRUD operations
if [[ "$auth_result" == *"WORKFLOW_CREATED:"* ]]; then
workflow_id=$(echo "$auth_result" | grep "WORKFLOW_CREATED:" | sed 's/.*WORKFLOW_CREATED://')
echo -e " ${GREEN}${NC} Workflow created (id: $workflow_id) [pass]"
if [[ "$auth_result" == *"WORKFLOW_VERIFIED"* ]]; then
echo -e " ${GREEN}${NC} Workflow retrieved successfully [pass]"
elif [[ "$auth_result" == *"WORKFLOW_VERIFY_FAILED:"* ]]; then
verify_error=$(echo "$auth_result" | grep "WORKFLOW_VERIFY_FAILED:" | sed 's/.*WORKFLOW_VERIFY_FAILED://')
echo -e " ${RED}${NC} Workflow verify failed: ${verify_error:0:100} [fail]"
fi
elif [[ "$auth_result" == *"WORKFLOW_CREATE_FAILED:"* ]]; then
create_error=$(echo "$auth_result" | grep "WORKFLOW_CREATE_FAILED:" | sed 's/.*WORKFLOW_CREATE_FAILED://')
echo -e " ${RED}${NC} Workflow create failed: ${create_error:0:100} [fail]"
fi
else
login_error=$(echo "$auth_result" | grep "LOGIN_FAILED:" | sed 's/.*LOGIN_FAILED://')
echo -e " ${RED}${NC} Login failed: ${login_error:0:100} [fail]"
fi
# Check n8n data directory exists
echo " Testing n8n data directory..."
assert_dir_exists "$node" "/var/lib/n8n" "n8n data directory"
# Check SQLite database file exists
echo " Testing SQLite database file..."
assert_file_exists "$node" "/var/lib/n8n/.n8n/database.sqlite" "SQLite database file"
# Check service is not in error state
echo " Checking service state..."
assert_service_running "$node" "n8n" "Service running normally"
# Check for any failed units related to n8n
echo " Checking for failed units..."
failed_units=$(cmd_clean "$node" "systemctl list-units --failed | grep -i n8n || echo 'none'")
if [[ "$failed_units" == *"none"* ]] || [[ -z "$failed_units" ]] || [[ ! "$failed_units" == *"failed"* ]]; then
echo -e " ${GREEN}${NC} No failed n8n related units [pass]"
else
echo -e " ${RED}${NC} Failed units found: $failed_units [fail]"
fi
# Check n8n process is running
echo " Checking n8n process..."
assert_process_running "$node" "-f n8n" "n8n"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "n8n Test Summary (SQLite)"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "n8n Test Complete"
echo "========================================"
@@ -0,0 +1,132 @@
{ config, pkgs, lib, ... }: {
# ==========================================================================
# PostgreSQL Database for Nextcloud (using infrastructure module)
# ==========================================================================
config.infrastructure.postgresql = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 5432;
initialDatabases = [ "nextcloud" ];
authentication = ''
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
};
# ==========================================================================
# Redis for Nextcloud Caching (using infrastructure module)
# ==========================================================================
config.infrastructure.redis = {
enable = true;
servers.nextcloud = {
bindToIp = "127.0.0.1";
bindToPort = 6379;
};
};
# ==========================================================================
# Nextcloud Configuration
# ==========================================================================
config.infrastructure.nextcloud = {
enable = true;
package = pkgs.nextcloud31;
hostName = "localhost";
https = false;
admin = {
user = "admin";
passwordFile = "/run/secrets/nextcloud-admin-pass";
};
database = {
type = "pgsql";
name = "nextcloud";
user = "nextcloud";
host = "/run/postgresql";
createLocally = true; # Creates the nextcloud user and grants permissions
};
caching = {
redis = true;
apcu = true;
};
maxUploadSize = "1G";
settings = {
default_phone_region = "US";
log_type = "file";
loglevel = 2;
};
};
# ==========================================================================
# Create admin password file
# ==========================================================================
config.systemd.services.nextcloud-create-admin-pass = {
description = "Create Nextcloud admin password file";
wantedBy = [ "multi-user.target" ];
before = [ "nextcloud-setup.service" ];
requiredBy = [ "nextcloud-setup.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /run/secrets
echo "testadminpass123" > /run/secrets/nextcloud-admin-pass
chmod 400 /run/secrets/nextcloud-admin-pass
chown nextcloud:nextcloud /run/secrets/nextcloud-admin-pass
'';
};
# ==========================================================================
# Ensure correct service ordering
# ==========================================================================
# Nextcloud setup depends on PostgreSQL and Redis
config.systemd.services.nextcloud-setup = {
after = [
"postgresql.service"
"redis-nextcloud.service"
"nextcloud-create-admin-pass.service"
];
requires = [
"postgresql.service"
];
wants = [
"redis-nextcloud.service"
"nextcloud-create-admin-pass.service"
];
};
# PHP-FPM depends on nextcloud-setup
config.systemd.services.phpfpm-nextcloud = {
after = [
"nextcloud-setup.service"
];
requires = [
"nextcloud-setup.service"
];
};
# Nginx depends on PHP-FPM
config.systemd.services.nginx = {
after = [
"phpfpm-nextcloud.service"
];
wants = [
"phpfpm-nextcloud.service"
];
};
# ==========================================================================
# Test utilities
# ==========================================================================
config.environment.systemPackages = with pkgs; [
curl
jq
];
}
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# Nextcloud test for nix-infra-machine
#
# This test:
# 1. Deploys Nextcloud with PostgreSQL, Redis, and Nginx
# 2. Verifies all services are running and started in correct order
# 3. Tests Nextcloud endpoints and functionality
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down Nextcloud test..."
# Stop services in reverse order
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop nginx 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop phpfpm-nextcloud 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop nextcloud-cron 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop redis-nextcloud 2>/dev/null || true'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop postgresql 2>/dev/null || true'
# Clean up data directories
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/nextcloud /var/lib/postgresql /var/lib/redis-nextcloud /run/secrets/nextcloud-admin-pass'
echo "Nextcloud teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Nextcloud Test"
echo "========================================"
echo ""
# Deploy the nextcloud configuration to test nodes
echo "Step 1: Deploying Nextcloud configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying Nextcloud deployment..."
echo ""
# Wait for services to start (Nextcloud has multiple dependencies)
for node in $TARGET; do
# Wait for backend services first
wait_for_service "$node" "postgresql" --timeout=30
wait_for_postgresql "$node" --timeout=30
wait_for_service "$node" "redis-nextcloud" --timeout=30
wait_for_redis "$node" "6379" --timeout=15
# Wait for nextcloud-setup oneshot to complete
wait_for_service_completed "$node" "nextcloud-setup" --timeout=120
# Wait for web services
wait_for_service "$node" "phpfpm-nextcloud" --timeout=30
wait_for_service "$node" "nginx" --timeout=30
wait_for_port "$node" "80" --timeout=15
wait_for_http "$node" "http://localhost/" "200 302 303" --timeout=60
done
# ============================================================================
# Check Service Status
# ============================================================================
echo ""
echo "Checking systemd services status..."
echo ""
for node in $TARGET; do
echo "Checking services on $node..."
# Check regular services
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 50
assert_service_active "$node" "redis-nextcloud" || show_service_logs "$node" "redis-nextcloud" 50
# Check oneshot service (nextcloud-setup)
assert_service_completed "$node" "nextcloud-setup" || show_service_logs "$node" "nextcloud-setup" 50
# Check remaining services
assert_service_active "$node" "phpfpm-nextcloud" || show_service_logs "$node" "phpfpm-nextcloud" 50
assert_service_active "$node" "nginx" || show_service_logs "$node" "nginx" 50
done
# ============================================================================
# Check Service Dependencies
# ============================================================================
echo ""
echo "Step 4: Verifying service dependencies..."
echo ""
for node in $TARGET; do
echo "Checking service dependencies on $node..."
# Check PostgreSQL started before nextcloud-setup
pg_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic postgresql --value")
nc_setup_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic nextcloud-setup --value")
assert_lt "$pg_start" "$nc_setup_start" "PostgreSQL started before nextcloud-setup"
# Check Redis started before nextcloud-setup
redis_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic redis-nextcloud --value")
assert_lt "$redis_start" "$nc_setup_start" "Redis started before nextcloud-setup"
# Check nextcloud-setup completed before phpfpm-nextcloud
phpfpm_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic phpfpm-nextcloud --value")
assert_lt "$nc_setup_start" "$phpfpm_start" "nextcloud-setup completed before phpfpm-nextcloud"
# Check phpfpm-nextcloud started before nginx
nginx_start=$(cmd_value "$node" "systemctl show -p ActiveEnterTimestampMonotonic nginx --value")
assert_lt "$phpfpm_start" "$nginx_start" "phpfpm-nextcloud started before nginx"
done
# ============================================================================
# Check Port Bindings
# ============================================================================
echo ""
echo "Step 5: Checking port bindings..."
echo ""
for node in $TARGET; do
echo "Checking ports on $node..."
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
assert_port_listening "$node" "6379" "Redis port 6379"
assert_port_listening "$node" "80" "HTTP port 80"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 6: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing Nextcloud on $node..."
# Test Nextcloud HTTP response
echo " Testing Nextcloud HTTP response..."
assert_http_status "$node" "http://localhost/" "200 302 303" "HTTP response"
# Test Nextcloud login page
echo " Testing Nextcloud login page..."
login_page=$(cmd_clean "$node" "curl -s -L http://localhost/login 2>/dev/null | head -c 2000")
if [[ "$login_page" == *"Nextcloud"* ]] || [[ "$login_page" == *"login"* ]]; then
echo -e " ${GREEN}${NC} Nextcloud login page is accessible [pass]"
else
echo -e " ${RED}${NC} Nextcloud login page not accessible [fail]"
echo " Response preview: ${login_page:0:200}..."
fi
# Test Nextcloud status endpoint
echo " Testing Nextcloud status endpoint..."
status_response=$(cmd_clean "$node" "curl -s http://localhost/status.php 2>/dev/null")
if assert_contains_all "$status_response" "Nextcloud is installed (status.php)" "installed" "true"; then
version=$(echo "$status_response" | grep -o '"versionstring":"[^"]*"' | cut -d'"' -f4)
if [[ -n "$version" ]]; then
print_info "Nextcloud version" "$version"
fi
fi
# Test PostgreSQL database connection
echo " Testing PostgreSQL database..."
db_check=$(cmd_clean "$node" "sudo -u postgres psql -l | grep nextcloud")
assert_contains "$db_check" "nextcloud" "Nextcloud database exists in PostgreSQL"
# Test Redis connection
echo " Testing Redis connection..."
redis_check=$(cmd_clean "$node" "redis-cli -p 6379 PING 2>/dev/null")
assert_contains "$redis_check" "PONG" "Redis is responding"
# Test Nextcloud OCC command
echo " Testing Nextcloud OCC command..."
occ_check=$(cmd_clean "$node" "sudo -u nextcloud /run/current-system/sw/bin/nextcloud-occ status 2>/dev/null")
assert_contains "$occ_check" "installed: true" "Nextcloud OCC reports installed"
# Test admin user exists
echo " Testing admin user exists..."
admin_check=$(cmd_clean "$node" "sudo -u nextcloud /run/current-system/sw/bin/nextcloud-occ user:list 2>/dev/null | grep admin")
assert_contains "$admin_check" "admin" "Admin user exists"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Nextcloud Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Nextcloud Test Complete"
echo "========================================"
@@ -0,0 +1,67 @@
{ config, pkgs, lib, ... }: {
# Enable nginx with test configuration
config.infrastructure.nginx = {
enable = true;
openFirewall = true;
recommendedSettings = true;
# Note: ACME/Let's Encrypt cannot be fully tested in VM environment
# as it requires DNS resolution and public internet access.
# For production, set acme.enable = true and acme.acceptTerms = true
acme = {
enable = false; # Disabled for testing
acceptTerms = false;
email = "test@example.com";
staging = true; # Use staging server to avoid rate limits
};
virtualHosts = {
# Simple static site
"localhost" = {
default = true;
root = "/var/www/test";
locations."/" = {
index = "index.html";
};
locations."/health" = {
return = "200 'OK'";
extraConfig = ''
add_header Content-Type text/plain;
'';
};
};
# Reverse proxy example (proxy to a test backend)
"proxy.localhost" = {
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
proxyWebsockets = true;
};
locations."/api" = {
proxyPass = "http://127.0.0.1:8081";
extraConfig = ''
proxy_read_timeout 300s;
'';
};
};
};
appendHttpConfig = ''
# Custom http config for testing
log_format custom '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent';
'';
};
# Create test web root directory with content
config.systemd.tmpfiles.rules = [
"d /var/www/test 0755 nginx nginx -"
"f /var/www/test/index.html 0644 nginx nginx - '<html><body><h1>Nginx Test Page</h1></body></html>'"
];
# Install utilities for testing
config.environment.systemPackages = with pkgs; [
curl
openssl
];
}
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Nginx test for nix-infra-machine
#
# This test:
# 1. Deploys nginx with virtual hosts configuration
# 2. Verifies the service is running
# 3. Tests HTTP endpoints
# 4. Tests virtual host routing
# 5. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down Nginx test..."
# Stop nginx service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop nginx 2>/dev/null || true'
# Clean up test web content
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/www/test'
echo "Nginx teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Nginx Test"
echo "========================================"
echo ""
# Deploy the nginx configuration to test nodes
echo "Step 1: Deploying Nginx configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying Nginx deployment..."
echo ""
# Wait for service and ports to be ready
for node in $TARGET; do
wait_for_service "$node" "nginx" --timeout=30
wait_for_port "$node" "80" --timeout=15
wait_for_http "$node" "http://127.0.0.1/" "200" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "nginx" || show_service_logs "$node" "nginx" 50
done
# Check if nginx process is running
echo ""
echo "Checking Nginx process..."
for node in $TARGET; do
assert_process_running "$node" "nginx" "Nginx"
done
# Check if HTTP port is listening
echo ""
echo "Checking HTTP port (80)..."
for node in $TARGET; do
assert_port_listening "$node" "80" "HTTP port 80"
done
# Check if HTTPS port is listening (even without certs, nginx binds)
echo ""
echo "Checking HTTPS port (443)..."
for node in $TARGET; do
port_check=$(cmd "$node" "ss -tlnp | grep ':443 '")
if [[ "$port_check" == *":443"* ]]; then
echo -e " ${GREEN}${NC} HTTPS port 443 is listening [pass]"
else
# This is expected to fail without SSL certificates configured
echo -e " ${GREEN}${NC} HTTPS port 443 not listening (expected without SSL cert) [pass]"
fi
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing Nginx on $node..."
# Test default virtual host - index page
echo " Testing default virtual host (index page)..."
index_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/")
assert_contains "$index_response" "Nginx Test Page" "Index page served correctly"
# Test health endpoint
echo " Testing health endpoint..."
health_response=$(cmd_clean "$node" "curl -s http://127.0.0.1/health")
assert_contains "$health_response" "OK" "Health endpoint returned OK"
# Test HTTP status code
echo " Testing HTTP status codes..."
assert_http_status "$node" "http://127.0.0.1/" "200" "HTTP 200 OK for index"
# Test 404 for non-existent path
echo " Testing 404 handling..."
assert_http_status "$node" "http://127.0.0.1/nonexistent" "404" "HTTP 404 for non-existent path"
# Test nginx configuration syntax using the nginx binary from nix store
echo " Testing nginx configuration syntax..."
config_test=$(cmd_clean "$node" "NGINX_BIN=\$(readlink -f /proc/\$(pgrep -o nginx)/exe) && \$NGINX_BIN -t 2>&1")
if [[ "$config_test" == *"syntax is ok"* ]] || [[ "$config_test" == *"test is successful"* ]]; then
echo -e " ${GREEN}${NC} Nginx configuration syntax valid [pass]"
else
echo -e " ${RED}${NC} Nginx configuration syntax error [fail]"
echo " $config_test"
fi
# Test Host header routing (proxy.localhost virtual host)
echo " Testing virtual host routing..."
proxy_code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' -H 'Host: proxy.localhost' http://127.0.0.1/ 2>/dev/null || echo '502'")
if [[ "$proxy_code" == "502" ]] || [[ "$proxy_code" == "504" ]]; then
echo -e " ${GREEN}${NC} Virtual host routing works (502/504 expected - no backend) [pass]"
else
print_info "Virtual host routing" "HTTP $proxy_code"
fi
# Test gzip compression is enabled
echo " Testing gzip compression..."
gzip_test=$(cmd_clean "$node" "curl -s -H 'Accept-Encoding: gzip' -I http://127.0.0.1/ | grep -i 'Content-Encoding' || echo 'no-gzip'")
if [[ "$gzip_test" == *"gzip"* ]]; then
echo -e " ${GREEN}${NC} Gzip compression enabled [pass]"
else
echo -e " ${GREEN}${NC} Gzip not applied (expected for small responses) [pass]"
fi
# Test server tokens are hidden (security)
echo " Testing server security headers..."
server_header=$(cmd_clean "$node" "curl -s -I http://127.0.0.1/ | grep -i '^Server:' || echo 'Server: hidden'")
if [[ "$server_header" != *"nginx/"* ]]; then
echo -e " ${GREEN}${NC} Server version hidden [pass]"
else
print_info "Server header" "$server_header"
fi
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Nginx Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Nginx Test Complete"
echo "========================================"
@@ -0,0 +1,12 @@
{ config, pkgs, lib, ... }: {
# Enable OpenSearch using the infrastructure module
infrastructure.opensearch = {
enable = true;
bindToIp = "127.0.0.1";
httpPort = 9201;
transportPort = 9301;
clusterName = "test-cluster";
singleNode = true;
heapSize = "512m";
};
}
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# OpenSearch standalone test for nix-infra-machine
#
# This test:
# 1. Deploys OpenSearch as a native service on custom port 9201
# 2. Verifies the service is running
# 3. Tests basic OpenSearch operations (index/query)
# 4. Cleans up on teardown
# Custom ports for testing
OPENSEARCH_HTTP_PORT=9201
OPENSEARCH_TRANSPORT_PORT=9301
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down OpenSearch test..."
# Stop OpenSearch service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop opensearch 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/opensearch'
echo "OpenSearch teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "OpenSearch Standalone Test (port $OPENSEARCH_HTTP_PORT)"
echo "========================================"
echo ""
# Deploy the opensearch configuration to test nodes
echo "Step 1: Deploying OpenSearch configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying OpenSearch deployment..."
echo ""
# Wait for OpenSearch service and API to be ready
for node in $TARGET; do
wait_for_service "$node" "opensearch" --timeout=60
wait_for_port "$node" "$OPENSEARCH_HTTP_PORT" --timeout=30
wait_for_elasticsearch "$node" "$OPENSEARCH_HTTP_PORT" --timeout=60 # Same API as Elasticsearch
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "opensearch" || show_service_logs "$node" "opensearch" 50
done
# Check if OpenSearch process is running
echo ""
echo "Checking OpenSearch process..."
for node in $TARGET; do
assert_process_running "$node" "-f opensearch" "OpenSearch"
done
# Check if OpenSearch HTTP port is listening
echo ""
echo "Checking OpenSearch HTTP port ($OPENSEARCH_HTTP_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$OPENSEARCH_HTTP_PORT" "HTTP port $OPENSEARCH_HTTP_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test OpenSearch connection and basic operations
for node in $TARGET; do
echo "Testing OpenSearch operations on $node..."
# Test cluster health endpoint
echo " Checking cluster health..."
health_result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$OPENSEARCH_HTTP_PORT/_cluster/health")
if assert_contains "$health_result" "cluster_name" "Cluster health endpoint accessible"; then
status=$(echo "$health_result" | jq -r '.status' 2>/dev/null || echo "unknown")
print_info "Cluster status" "$status"
fi
# Create a test index
echo " Creating test index..."
create_result=$(cmd_clean "$node" "curl -s -X PUT 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index' -H 'Content-Type: application/json' -d '{\"settings\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}}'")
assert_contains_all "$create_result" "Index creation successful" "acknowledged" "true"
# Insert a test document
echo " Inserting test document..."
insert_result=$(cmd_clean "$node" "curl -s -X POST 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_doc/1' -H 'Content-Type: application/json' -d '{\"name\": \"test\", \"value\": 42}'")
if [[ "$insert_result" == *"created"* ]] || [[ "$insert_result" == *"_id"* ]]; then
echo -e " ${GREEN}${NC} Document insert successful [pass]"
else
echo -e " ${RED}${NC} Document insert failed: $insert_result [fail]"
fi
# Force refresh to make document searchable
cmd "$node" "curl -s -X POST 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_refresh'" > /dev/null 2>&1
# Query the test document
echo " Querying test document..."
query_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_doc/1'")
assert_contains_all "$query_result" "Document query successful" "found" "true"
# Test search functionality
echo " Testing search..."
search_result=$(cmd_clean "$node" "curl -s -X GET 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index/_search' -H 'Content-Type: application/json' -d '{\"query\": {\"match\": {\"name\": \"test\"}}}'")
assert_contains_all "$search_result" "Search operation successful" "hits" "value"
# List indices
echo " Listing indices..."
indices_result=$(cmd_clean "$node" "curl -s 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/_cat/indices?v'")
assert_contains "$indices_result" "test-index" "Index listing successful"
# Clean up test index
echo " Cleaning up test index..."
cmd "$node" "curl -s -X DELETE 'http://127.0.0.1:$OPENSEARCH_HTTP_PORT/test-index'" > /dev/null 2>&1
print_cleanup "Test index cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "OpenSearch Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "OpenSearch Test Complete"
echo "========================================"
@@ -0,0 +1,9 @@
{ config, pkgs, lib, ... }: {
# Enable PostgreSQL using the infrastructure module
infrastructure.postgresql = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 5432;
initialDatabases = [ "testdb" ];
};
}
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# PostgreSQL standalone test for nix-infra-machine
#
# This test:
# 1. Deploys PostgreSQL as a native service
# 2. Verifies the service is running
# 3. Tests basic PostgreSQL operations (create table, insert, query)
# 4. Cleans up on teardown
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down PostgreSQL test..."
# Stop PostgreSQL service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop postgresql 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/postgresql'
echo "PostgreSQL teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "PostgreSQL Standalone Test"
echo "========================================"
echo ""
# Deploy the postgresql configuration to test nodes
echo "Step 1: Deploying PostgreSQL configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying PostgreSQL deployment..."
echo ""
# Wait for service and database to be ready
for node in $TARGET; do
wait_for_service "$node" "postgresql" --timeout=30
wait_for_port "$node" "5432" --timeout=15
wait_for_postgresql "$node" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "postgresql" || show_service_logs "$node" "postgresql" 30
done
# Check if PostgreSQL process is running
echo ""
echo "Checking PostgreSQL process..."
for node in $TARGET; do
assert_process_running "$node" "postgres" "PostgreSQL"
done
# Check if PostgreSQL port is listening
echo ""
echo "Checking PostgreSQL port (5432)..."
for node in $TARGET; do
assert_port_listening "$node" "5432" "PostgreSQL port 5432"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test PostgreSQL connection and basic operations
for node in $TARGET; do
echo "Testing PostgreSQL operations on $node..."
# Test connection
echo " Testing connection..."
conn_result=$(cmd_clean "$node" "sudo -u postgres psql -c 'SELECT 1 as test;' 2>&1")
assert_contains "$conn_result" "1" "Connection successful"
# Check if testdb was created
echo " Checking testdb database..."
db_check=$(cmd_clean "$node" "sudo -u postgres psql -l | grep testdb")
assert_contains "$db_check" "testdb" "Database 'testdb' exists"
# Create a test table
echo " Creating test table..."
create_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c 'CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name VARCHAR(100), value INTEGER);' 2>&1")
if [[ "$create_result" == *"CREATE TABLE"* ]] || [[ "$create_result" == *"already exists"* ]] || [[ -z "$create_result" ]]; then
echo -e " ${GREEN}${NC} Create table successful [pass]"
else
echo -e " ${RED}${NC} Create table failed: $create_result [fail]"
fi
# Insert a test record
echo " Inserting test record..."
insert_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c \"INSERT INTO test_table (name, value) VALUES ('test', 42);\" 2>&1")
assert_contains "$insert_result" "INSERT" "Insert operation successful"
# Query the test record
echo " Querying test record..."
query_result=$(cmd_clean "$node" "sudo -u postgres psql -d testdb -c 'SELECT * FROM test_table WHERE name = '\\''test'\\'';' 2>&1")
assert_contains_all "$query_result" "Query operation successful" "test" "42"
# Test database listing
echo " Listing databases..."
db_list=$(cmd_clean "$node" "sudo -u postgres psql -c '\\l' 2>&1")
assert_contains "$db_list" "postgres" "Database listing successful"
# Clean up test data
echo " Cleaning up test data..."
cmd "$node" "sudo -u postgres psql -d testdb -c 'DROP TABLE IF EXISTS test_table;'" > /dev/null 2>&1
print_cleanup "Test data cleaned up"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "PostgreSQL Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "PostgreSQL Test Complete"
echo "========================================"
@@ -0,0 +1,14 @@
{ config, pkgs, lib, ... }: {
# Enable RabbitMQ using the infrastructure module
infrastructure.rabbitmq = {
enable = true;
bindToIp = "127.0.0.1";
bindToPort = 5672;
# Enable management plugin for testing
managementPlugin = {
enable = true;
port = 15672;
};
};
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# RabbitMQ standalone test for nix-infra-machine
#
# This test:
# 1. Deploys RabbitMQ as a native service
# 2. Verifies the service is running
# 3. Tests basic RabbitMQ operations (queue creation, publish/consume)
# 4. Tests the management API
# 5. Cleans up on teardown
# Ports for testing
RABBITMQ_PORT=5672
MANAGEMENT_PORT=15672
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down RabbitMQ test..."
# Stop RabbitMQ service
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'systemctl stop rabbitmq 2>/dev/null || true'
# Clean up data directory
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/rabbitmq'
echo "RabbitMQ teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "RabbitMQ Standalone Test"
echo " AMQP port: $RABBITMQ_PORT"
echo " Management port: $MANAGEMENT_PORT"
echo "========================================"
echo ""
# Deploy the rabbitmq configuration to test nodes
echo "Step 1: Deploying RabbitMQ configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying RabbitMQ deployment..."
echo ""
# Wait for service and ports to be ready
for node in $TARGET; do
wait_for_service "$node" "rabbitmq" --timeout=60
wait_for_port "$node" "$RABBITMQ_PORT" --timeout=30
wait_for_port "$node" "$MANAGEMENT_PORT" --timeout=30
done
# Check if the systemd service is active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
assert_service_active "$node" "rabbitmq" || show_service_logs "$node" "rabbitmq" 30
done
echo ""
echo "Checking RabbitMQ process..."
for node in $TARGET; do
assert_process_running "$node" "beam.smp" "RabbitMQ (Erlang VM)"
done
# Check if AMQP port is listening
echo ""
echo "Checking AMQP port ($RABBITMQ_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$RABBITMQ_PORT" "AMQP port $RABBITMQ_PORT"
done
# Check if Management port is listening
echo ""
echo "Checking Management port ($MANAGEMENT_PORT)..."
for node in $TARGET; do
assert_port_listening "$node" "$MANAGEMENT_PORT" "Management port $MANAGEMENT_PORT"
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
for node in $TARGET; do
echo "Testing RabbitMQ operations on $node..."
# Test management API - get overview
echo " Testing management API..."
api_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/overview")
assert_contains "$api_result" "rabbitmq_version" "Management API overview"
# Test creating a queue via management API
echo " Creating test queue..."
create_result=$(cmd_clean "$node" "curl -s -u guest:guest -X PUT -H 'Content-Type: application/json' \
-d '{\"durable\":false,\"auto_delete\":false}' \
http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue")
# Empty response or no error means success
if [[ -z "$create_result" ]] || [[ "$create_result" != *"error"* ]]; then
echo -e " ${GREEN}${NC} Queue creation successful [pass]"
else
echo -e " ${RED}${NC} Queue creation failed: $create_result [fail]"
fi
# Small delay to ensure queue is ready
sleep 1
# Test listing queues (do this before publish to verify queue exists)
echo " Listing queues..."
queues_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/queues")
assert_contains "$queues_result" "test-queue" "Queue listing"
# Test listing exchanges
echo " Listing exchanges..."
exchanges_result=$(cmd_clean "$node" "curl -s -u guest:guest http://127.0.0.1:$MANAGEMENT_PORT/api/exchanges")
assert_contains "$exchanges_result" "amq.direct" "Exchange listing"
# Test publishing a message
echo " Publishing test message..."
publish_result=$(cmd_clean "$node" "curl -s -u guest:guest -X POST -H 'Content-Type: application/json' \
-d '{\"properties\":{},\"routing_key\":\"test-queue\",\"payload\":\"HelloRabbitMQ\",\"payload_encoding\":\"string\"}' \
http://127.0.0.1:$MANAGEMENT_PORT/api/exchanges/%2F/amq.default/publish")
assert_contains "$publish_result" "routed" "Message publish"
# Small delay to ensure message is available
sleep 1
# Test getting messages from the queue
echo " Consuming test message..."
consume_result=$(cmd_clean "$node" "curl -s -u guest:guest -X POST -H 'Content-Type: application/json' \
-d '{\"count\":1,\"ackmode\":\"ack_requeue_false\",\"encoding\":\"auto\"}' \
http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue/get")
assert_contains "$consume_result" "HelloRabbitMQ" "Message consume"
# Clean up test queue
echo " Cleaning up test queue..."
cmd "$node" "curl -s -u guest:guest -X DELETE http://127.0.0.1:$MANAGEMENT_PORT/api/queues/%2F/test-queue" > /dev/null 2>&1
print_cleanup "Test queue deleted"
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "RabbitMQ Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "RabbitMQ Test Complete"
echo "========================================"
@@ -0,0 +1,20 @@
{ config, pkgs, lib, ... }: {
# Enable Redis using infrastructure module with multiple servers
config.infrastructure.redis = {
enable = true;
servers = {
# Default server (creates redis.service)
"" = {
bindToIp = "127.0.0.1";
bindToPort = 6379;
};
# Named server for testing (creates redis-cache.service)
cache = {
bindToIp = "127.0.0.1";
bindToPort = 6380;
maxMemory = "64mb";
maxMemoryPolicy = "allkeys-lru";
};
};
};
}
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Redis test for nix-infra-machine
#
# This test:
# 1. Deploys multiple Redis servers using infrastructure.redis
# 2. Verifies all services are running
# 3. Tests basic Redis operations on each server
# 4. Cleans up on teardown
# Server configurations: name:port
declare -A REDIS_SERVERS=(
["redis"]=6379
["redis-cache"]=6380
)
# Handle teardown command
if [ "$CMD" = "teardown" ]; then
echo "Tearing down Redis test..."
# Stop Redis services
for server in "${!REDIS_SERVERS[@]}"; do
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
"systemctl stop $server 2>/dev/null || true"
done
# Clean up data directories
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" \
'rm -rf /var/lib/redis /var/lib/redis-cache'
echo "Redis teardown complete"
return 0
fi
# ============================================================================
# Test Setup
# ============================================================================
_start=$(date +%s)
echo ""
echo "========================================"
echo "Redis Multi-Server Test"
echo "========================================"
echo ""
# Deploy the redis configuration to test nodes
echo "Step 1: Deploying Redis configuration..."
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--test-dir="$WORK_DIR/$TEST_DIR" \
--target="$TARGET"
# Apply the configuration
echo "Step 2: Applying NixOS configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_setup=$(date +%s)
# ============================================================================
# Test Verification
# ============================================================================
echo ""
echo "Step 3: Verifying Redis deployment..."
echo ""
# Wait for services to start
for node in $TARGET; do
for server in "${!REDIS_SERVERS[@]}"; do
port=${REDIS_SERVERS[$server]}
wait_for_service "$node" "$server" --timeout=30
wait_for_redis "$node" "$port" --timeout=15
done
done
# Check if the systemd services are active
echo ""
echo "Checking systemd service status..."
for node in $TARGET; do
for server in "${!REDIS_SERVERS[@]}"; do
assert_service_active "$node" "$server" || show_service_logs "$node" "$server" 30
done
done
# Check if Redis processes are running
echo ""
echo "Checking Redis processes..."
for node in $TARGET; do
expected_count=${#REDIS_SERVERS[@]}
assert_process_count "$node" "redis-server" "$expected_count" "Redis"
done
# Check if Redis ports are listening
echo ""
echo "Checking Redis ports..."
for node in $TARGET; do
for server in "${!REDIS_SERVERS[@]}"; do
port=${REDIS_SERVERS[$server]}
assert_port_listening "$node" "$port" "$server port $port"
done
done
# ============================================================================
# Functional Tests
# ============================================================================
echo ""
echo "Step 4: Running functional tests..."
echo ""
# Test Redis connection and basic operations on each server
for node in $TARGET; do
for server in "${!REDIS_SERVERS[@]}"; do
port=${REDIS_SERVERS[$server]}
echo "Testing $server (port $port) on $node..."
# Test PING command
echo " Testing PING command..."
ping_result=$(cmd_clean "$node" "redis-cli -p $port PING")
assert_contains "$ping_result" "PONG" "PING successful"
# Test SET command
echo " Testing SET command..."
set_result=$(cmd_clean "$node" "redis-cli -p $port SET testkey-$server 'hello-from-$server'")
assert_contains "$set_result" "OK" "SET operation successful"
# Test GET command
echo " Testing GET command..."
get_result=$(cmd_clean "$node" "redis-cli -p $port GET testkey-$server")
assert_contains "$get_result" "hello-from-$server" "GET operation successful"
# Test INCR command
echo " Testing INCR command..."
cmd "$node" "redis-cli -p $port SET counter 0" > /dev/null 2>&1
incr_result=$(cmd_clean "$node" "redis-cli -p $port INCR counter")
assert_contains "$incr_result" "1" "INCR operation successful"
# Test INFO command
echo " Testing INFO command..."
info_result=$(cmd_clean "$node" "redis-cli -p $port INFO server | head -5")
assert_contains "$info_result" "redis_version" "INFO command successful"
# Clean up test data
echo " Cleaning up test data..."
cmd "$node" "redis-cli -p $port FLUSHALL" > /dev/null 2>&1
print_cleanup "Test data cleaned up"
echo ""
done
done
# ============================================================================
# Test Server Isolation
# ============================================================================
echo "Step 5: Testing server isolation..."
echo ""
for node in $TARGET; do
echo "Testing data isolation on $node..."
# Set a key on the default server
cmd "$node" "redis-cli -p 6379 SET isolation-test 'default-server'" > /dev/null 2>&1
# Try to get it from the cache server (should not exist)
cache_result=$(cmd_value "$node" "redis-cli -p 6380 GET isolation-test")
assert_empty_or_nil "$cache_result" "Servers are properly isolated"
# Clean up
cmd "$node" "redis-cli -p 6379 FLUSHALL" > /dev/null 2>&1
done
# ============================================================================
# Test Summary
# ============================================================================
_end=$(date +%s)
echo ""
echo "========================================"
echo "Redis Test Summary"
echo "========================================"
printf '+ setup %s\n' $(printTime $_start $_setup)
printf '+ tests %s\n' $(printTime $_setup $_end)
printf '= TOTAL %s\n' $(printTime $_start $_end)
echo ""
echo "========================================"
echo "Redis Test Complete"
echo "========================================"
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
WORK_DIR=${WORK_DIR:-$(dirname "$SCRIPT_DIR")}
NIX_INFRA=${NIX_INFRA:-"nix-infra"}
NIXOS_VERSION=${NIXOS_VERSION:-"25.11"}
MACHINE_TYPE=${MACHINE_TYPE:-"cpx22"}
SSH_KEY="nixinfra-machine"
SSH_EMAIL=${SSH_EMAIL:-your-email@example.com}
ENV=${ENV:-.env}
SECRETS_PWD=${SECRETS_PWD:-my_secrets_password}
TARGET=${TARGET:-"testnode001"}
read -r -d '' __help_text__ <<EOF || true
nix-infra-machine Test Runner
=============================
Usage: $0 <command> [options]
Commands:
create Provision and initialize test machines
run <test-name> Run a specific test (e.g., mongodb)
reset <test-name> Reset test state without destroying machines
destroy Tear down all test machines
status Run basic health checks on machines
update <nodes> Update node configuration
upgrade <nodes> Upgrade NixOS version on nodes
ssh <node> SSH into a node
cmd --target=<node> <command> Run command on node(s)
action --target=<node> <module> <cmd> Run app action
port-forward --target=<node> --port-mapping=<local:remote>
Options:
--env=<file> Environment file (default: .env)
--no-teardown Don't tear down after test
--target=<nodes> Target node(s) for commands
--nixos-version=<version> Override version
--machine-type=<type> Override machine type
Examples:
# Run the full test cycle
$0 create --env=.env
$0 run mongodb --env=.env
$0 destroy --env=.env
# Run test without teardown for debugging
$0 run mongodb --no-teardown --env=.env
# Interactive debugging
$0 ssh testnode001 --env=.env
$0 cmd --target=testnode001 --env=.env "systemctl status podman-mongodb-4"
# Reset and re-run a test
$0 reset mongodb --env=.env
$0 run mongodb --env=.env
EOF
if [[ "create upgrade run reset destroy update status ssh cmd action port-forward" == *"$1"* ]]; then
CMD="$1"
shift
else
echo "$__help_text__"
exit 1
fi
for i in "$@"; do
case $i in
--help)
echo "$__help_text__"
exit 0
;;
--no-teardown)
NO_TEARDOWN="true"
shift
;;
--env=*)
ENV="${i#*=}"
shift
;;
--target=*)
TARGET="${i#*=}"
shift
;;
--port-mapping=*)
PORT_MAPPING="${i#*=}"
shift
;;
--nixos-version=*)
NIXOS_VERSION="${i#*=}"
shift
;;
--machine-type=*)
MACHINE_TYPE="${i#*=}"
shift
;;
*)
REST="$@"
;;
esac
done
if [ "$ENV" != "" ] && [ -f "$ENV" ]; then
source $ENV
fi
# Check for nix-infra CLI if using default
if [ "$NIX_INFRA" = "nix-infra" ] && ! command -v nix-infra >/dev/null 2>&1; then
echo "The 'nix-infra' CLI is required for this script to work."
echo "Visit https://github.com/jhsware/nix-infra for installation instructions."
exit 1
fi
if [ -z "$HCLOUD_TOKEN" ]; then
echo "Missing env-var HCLOUD_TOKEN. Load through .env-file that is specified through --env."
exit 1
fi
# Source shared helpers
source "$SCRIPT_DIR/shared.sh"
source "$SCRIPT_DIR/assertions.sh"
source "$SCRIPT_DIR/timeouts.sh"
# ============================================================================
# Test Runner Commands
# ============================================================================
if [ "$CMD" = "run" ]; then
if [ ! -d "$WORK_DIR" ]; then
echo "Working directory doesn't exist ($WORK_DIR)"
exit 1
fi
if [ "$REST" == "" ]; then
echo "Missing test name. Available tests:"
ls -d "$WORK_DIR/__test__"/*/ 2>/dev/null | xargs -n1 basename | grep -v "^$"
exit 1
fi
last_test="${REST##* }"
for _test_name in $REST; do
if [ ! -d "$WORK_DIR/__test__/$_test_name" ]; then
echo "Test directory doesn't exist (__test__/$_test_name)"
else
echo "========================================"
echo "Running test: $_test_name"
echo "========================================"
TEST_DIR="__test__/$_test_name" source "$WORK_DIR/__test__/$_test_name/test.sh"
if [ "$_test_name" != "$last_test" ] || [ "$NO_TEARDOWN" != "true" ]; then
echo "Cleaning up after test: $_test_name"
TEST_DIR="__test__/$_test_name" CMD="teardown" source "$WORK_DIR/__test__/$_test_name/test.sh"
fi
fi
done
if [ "$NO_TEARDOWN" != "true" ]; then
echo "Resetting node configurations..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" 'rm -f /etc/nixos/$(hostname).nix'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
fi
exit 0
fi
if [ "$CMD" = "reset" ]; then
if [ ! -d "$WORK_DIR" ]; then
echo "Working directory doesn't exist ($WORK_DIR)"
exit 1
fi
if [ "$REST" == "" ]; then
echo "Missing test name"
exit 1
fi
echo "Cleaning up node configuration..."
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" 'rm -f /etc/nixos/$(hostname).nix'
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
sleep 1
echo "Running test teardown..."
for _test_name in $REST; do
if [ ! -d "$WORK_DIR/__test__/$_test_name" ]; then
echo "Test directory doesn't exist (__test__/$_test_name)"
else
TEST_DIR="__test__/$_test_name" CMD="teardown" source "$WORK_DIR/__test__/$_test_name/test.sh"
fi
done
echo "Removing secrets..."
rm -f "$WORK_DIR/secrets/"*
echo "...reset complete!"
exit 0
fi
# ============================================================================
# Fleet Management Commands
# ============================================================================
destroyFleet() {
$NIX_INFRA fleet destroy -d "$WORK_DIR" --batch \
--target="$TARGET"
$NIX_INFRA ssh-key remove -d "$WORK_DIR" --batch --name="$SSH_KEY"
echo "Remove /secrets..."
rm -rf "$WORK_DIR/secrets"
}
cleanupOnFail() {
if [ $1 -ne 0 ]; then
echo "$2"
destroyFleet
exit 1
fi
}
if [ "$CMD" = "destroy" ]; then
destroyFleet
exit 0
fi
if [ "$CMD" = "status" ]; then
testFleet "$TARGET"
exit 0
fi
if [ "$CMD" = "update" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 update --env=$ENV [node1 node2 ...]"
exit 1
fi
$NIX_INFRA fleet update -d "$WORK_DIR" --batch --env="$ENV" \
--nixos-version="$NIXOS_VERSION" \
--node-module="node_types/standalone_machine.nix" \
--target="$REST" \
--rebuild
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --batch --env="$ENV" \
--target="$REST"
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$REST" "nixos-rebuild switch --fast"
exit 0
fi
if [ "$CMD" = "upgrade" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 upgrade --env=$ENV [node1 node2 ...]"
exit 1
fi
$NIX_INFRA fleet upgrade-nixos -d "$WORK_DIR" --batch --env="$ENV" --nixos-version="$NIXOS_VERSION" \
--target="$REST"
exit 0
fi
# ============================================================================
# Interactive Commands
# ============================================================================
if [ "$CMD" = "ssh" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 ssh --env=$ENV [node]"
exit 1
fi
$NIX_INFRA fleet ssh -d "$WORK_DIR" --env="$ENV" --target="$REST"
exit 0
fi
if [ "$CMD" = "cmd" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 cmd --env=$ENV --target=[node] [cmd goes here]"
exit 1
fi
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "$REST"
exit 0
fi
if [ "$CMD" = "action" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 action --env=$ENV --target=[node] [module] [cmd]"
exit 1
fi
read -r module cmd <<< "$REST"
$NIX_INFRA fleet action -d "$WORK_DIR" --target="$TARGET" --app-module="$module" \
--cmd="$cmd"
exit 0
fi
if [ "$CMD" = "port-forward" ]; then
if [ -z "$TARGET" ] || [ -z "$PORT_MAPPING" ]; then
echo "Usage: $0 port-forward --env=$ENV --target=[node] --port-mapping=[local:remote]"
exit 1
fi
OLD_IFS=$IFS
IFS=: read LOCAL_PORT REMOTE_PORT <<< "$PORT_MAPPING"
IFS=$OLD_IFS
$NIX_INFRA fleet port-forward -d "$WORK_DIR" --env="$ENV" \
--target="$TARGET" \
--local-port="$LOCAL_PORT" \
--remote-port="$REMOTE_PORT"
exit 0
fi
# ============================================================================
# Create Command - Provision and Initialize Test Fleet
# ============================================================================
if [ "$CMD" = "create" ]; then
if [ -d "$WORK_DIR/secrets" ]; then
echo "Found existing ./secrets, this appears to be a live project. Creating a test environment may destroy it."
exit 1
fi
if [ ! -f "$ENV" ]; then
read -r -d '' env <<EOF || true
# NOTE: The following secrets are required for various operations
# by the nix-infra CLI. Make sure they are encrypted when not in use
SSH_KEY=$SSH_KEY
SSH_EMAIL=$SSH_EMAIL
# The following token is needed to perform provisioning and discovery
HCLOUD_TOKEN=$HCLOUD_TOKEN
# Password for the secrets that are stored in this repo
# These need to be kept secret.
SECRETS_PWD=$SECRETS_PWD
EOF
echo "$env" > "$WORK_DIR/.env"
fi
_start=$(date +%s)
$NIX_INFRA init -d "$WORK_DIR" --no-cert-auth --batch
ssh-add "$WORK_DIR/ssh/$SSH_KEY"
echo "*** Provisioning NixOS $NIXOS_VERSION ***"
$NIX_INFRA fleet provision -d "$WORK_DIR" --batch --env="$ENV" \
--nixos-version="$NIXOS_VERSION" \
--ssh-key=$SSH_KEY \
--location=hel1 \
--machine-type="$MACHINE_TYPE" \
--node-names="$TARGET"
cleanupOnFail $? "WARNING: Provisioning failed! Cleaning up..."
_provision=$(date +%s)
$NIX_INFRA fleet init-machine -d "$WORK_DIR" --batch --env="$ENV" \
--nixos-version="$NIXOS_VERSION" \
--target="$TARGET" \
--node-module="node_types/standalone_machine.nix"
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "nixos-rebuild switch --fast"
_init_nodes=$(date +%s)
# Verify the operation of the test fleet
echo "******************************************"
testFleet "$TARGET"
echo "******************************************"
_end=$(date +%s)
echo " ** ** "
echo " ** ** "
echo "******************************************"
printTime() {
local _start=$1; local _end=$2; local _secs=$((_end-_start))
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
}
printf '+ provision %s\n' "$(printTime $_start $_provision)"
printf '+ init %s\n' "$(printTime $_provision $_init_nodes)"
printf '+ test %s\n' "$(printTime $_init_nodes $_end)"
printf '= SUM %s\n' "$(printTime $_start $_end)"
echo "***************** DONE *******************"
fi
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
# Shared helper functions for nix-infra-machine tests
# ============================================================================
# Colors for Test Output
# ============================================================================
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# ============================================================================
# Utility Functions
# ============================================================================
appendWithLineBreak() {
if [ -z "$1" ]; then
printf '%s' "$2"
else
printf '%s\n%s' "$1" "$2"
fi
}
cmd() {
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$1" "$2"
}
# Get command output with node prefix stripped and whitespace trimmed
# Use for single values that need arithmetic or exact comparison
# Example: count=$(cmd_value "$node" "pgrep -c redis-server || echo 0")
cmd_value() {
local node="$1"
local command="$2"
local output
output=$(cmd "$node" "$command")
# Strip "nodename: " prefix and trim whitespace
echo "$output" | sed "s/^${node}: //" | tr -d '[:space:]'
}
# Get command output with node prefix stripped but preserving structure
# Use for multi-line output or when whitespace matters
# Example: config=$(cmd_clean "$node" "cat /etc/config")
cmd_clean() {
local node="$1"
local command="$2"
local output
output=$(cmd "$node" "$command")
# Strip "nodename: " prefix from each line
echo "$output" | sed "s/^${node}: //"
}
printTime() {
local _start=$1; local _end=$2; local _secs=$((_end-_start))
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
}
# ============================================================================
# Common Commands (used by run-tests.sh command parsing)
# ============================================================================
if [ "$CMD" = "pull" ]; then
git -C "$WORK_DIR" pull
exit 0
fi
if [ "$CMD" = "ssh" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 ssh --env=$ENV [node]"
exit 1
fi
HCLOUD_TOKEN=$HCLOUD_TOKEN hcloud server ssh $REST -i "$WORK_DIR/ssh/$SSH_KEY"
exit 0
fi
if [ "$CMD" = "cmd" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 cmd --env=$ENV --target=[node] [cmd goes here]"
exit 1
fi
$NIX_INFRA fleet cmd -d "$WORK_DIR" --target="$TARGET" "$REST"
exit 0
fi
if [ "$CMD" = "action" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 action --env=$ENV --target=[node] [module] [cmd]"
exit 1
fi
read -r module action_cmd <<< "$REST"
$NIX_INFRA fleet action -d "$WORK_DIR" --target="$TARGET" --app-module="$module" \
--cmd="$action_cmd"
exit 0
fi
# ============================================================================
# Health Check Functions
# ============================================================================
checkNixos() {
echo "Checking NixOS..."
local NODES="$1"
local node
local _nixos_fail=""
for node in $NODES; do
local output=$(cmd "$node" "uname -a" 2>&1)
local result="$output"
if [[ "$result" == *"NixOS"* ]]; then
echo " ✓ nixos: ok ($node)"
else
echo " ✗ nixos: fail ($node)"
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
echo " $output"
fi
_nixos_fail="true"
fi
done
if [ -n "$_nixos_fail" ]; then
return 1
fi
}
checkPodman() {
echo "Checking Podman..."
local NODES="$1"
local node
local _failed=""
for node in $NODES; do
local output=$(cmd "$node" "podman --version" 2>&1)
local result="$output"
if [[ "$result" == *"podman version"* ]]; then
echo " ✓ podman: ok ($node)"
else
echo " ✗ podman: not installed or not running ($node)"
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
echo " $output"
fi
_failed="yes"
fi
done
if [ -n "$_failed" ]; then
return 1
fi
}
checkService() {
local NODE="$1"
local SERVICE="$2"
local output=$(cmd "$NODE" "systemctl is-active $SERVICE" 2>&1)
local result="$output"
if [[ "$result" == *"active"* ]]; then
echo "$SERVICE: active ($NODE)"
return 0
else
echo "$SERVICE: inactive ($NODE)"
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
echo " $output"
fi
return 1
fi
}
checkServiceOnNodes() {
echo "Checking service: $2"
local NODES="$1"
local SERVICE="$2"
local node
local _failed=""
for node in $NODES; do
if ! checkService "$node" "$SERVICE"; then
_failed="yes"
fi
done
if [ -n "$_failed" ]; then
return 1
fi
}
checkHttpEndpoint() {
local NODE="$1"
local URL="$2"
local EXPECTED="$3"
local output=$(cmd "$NODE" "curl -s --max-time 5 '$URL'" 2>&1)
local result="$output"
if [[ "$result" == *"$EXPECTED"* ]]; then
echo " ✓ HTTP $URL: ok ($NODE)"
return 0
else
echo " ✗ HTTP $URL: expected '$EXPECTED' ($NODE)"
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
echo " $output"
fi
return 1
fi
}
checkTcpPort() {
local NODE="$1"
local HOST="$2"
local PORT="$3"
local output=$(cmd "$NODE" "nc -zv $HOST $PORT 2>&1")
local result="$output"
if [[ "$result" == *"succeeded"* ]] || [[ "$result" == *"open"* ]] || [[ "$result" == *"Connected"* ]]; then
echo " ✓ TCP $HOST:$PORT: open ($NODE)"
return 0
else
echo " ✗ TCP $HOST:$PORT: closed ($NODE)"
if [ -n "$output" ] && [[ "$output" == ERROR:* ]]; then
echo " $output"
fi
return 1
fi
}
# ============================================================================
# Fleet Test Function
# ============================================================================
testFleet() {
local NODES="$1"
echo "=========================================="
echo "Running Fleet Health Checks"
echo "=========================================="
local _failed=""
if ! checkNixos "$NODES"; then
_failed="yes"
fi
echo "=========================================="
if [ -n "$_failed" ]; then
echo "Health checks: FAILED"
return 1
else
echo "Health checks: PASSED"
return 0
fi
}
# ============================================================================
# Container/Pod Test Helpers
# ============================================================================
waitForContainer() {
local NODE="$1"
local CONTAINER="$2"
local TIMEOUT="${3:-60}"
echo "Waiting for container $CONTAINER on $NODE (timeout: ${TIMEOUT}s)..."
local elapsed=0
local last_output=""
while [ $elapsed -lt $TIMEOUT ]; do
last_output=$(cmd "$NODE" "podman ps --filter name=$CONTAINER --format '{{.Status}}'" 2>&1)
local status="$last_output"
if [[ "$status" == *"Up"* ]]; then
echo " ✓ Container $CONTAINER is running"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo " ✗ Container $CONTAINER did not start within ${TIMEOUT}s"
if [ -n "$last_output" ] && [[ "$last_output" == ERROR:* ]]; then
echo " $last_output"
fi
return 1
}
waitForService() {
local NODE="$1"
local SERVICE="$2"
local TIMEOUT="${3:-60}"
echo "Waiting for service $SERVICE on $NODE (timeout: ${TIMEOUT}s)..."
local elapsed=0
local last_output=""
while [ $elapsed -lt $TIMEOUT ]; do
last_output=$(cmd "$NODE" "systemctl is-active $SERVICE" 2>&1)
local status="$last_output"
if [[ "$status" == *"active"* ]]; then
echo " ✓ Service $SERVICE is active"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo " ✗ Service $SERVICE did not become active within ${TIMEOUT}s"
if [ -n "$last_output" ] && [[ "$last_output" == ERROR:* ]]; then
echo " $last_output"
fi
return 1
}
getContainerLogs() {
local NODE="$1"
local CONTAINER="$2"
local LINES="${3:-50}"
echo "--- Container logs for $CONTAINER on $NODE ---"
cmd "$NODE" "podman logs --tail $LINES $CONTAINER" 2>&1
echo "--- End of logs ---"
}
getServiceLogs() {
local NODE="$1"
local SERVICE="$2"
local LINES="${3:-50}"
echo "--- Service logs for $SERVICE on $NODE ---"
cmd "$NODE" "journalctl -n $LINES -u $SERVICE" 2>&1
echo "--- End of logs ---"
}
# ============================================================================
# Info output
# ============================================================================
# Print info line (not pass/fail)
# Usage: print_info "label" "value"
print_info() {
local label="$1"
local value="$2"
echo -e " ${GREEN}${NC} $label: $value [info]"
}
# Print cleanup success
# Usage: print_cleanup "label"
print_cleanup() {
local label="$1"
echo -e " ${GREEN}${NC} $label [pass]"
}
+642
View File
@@ -0,0 +1,642 @@
#!/usr/bin/env bash
# Timeout handling library for nix-infra-machine tests
# Provides functions to wait for conditions with configurable timeouts
#
# All wait functions follow this pattern:
# wait_for_* [args...] [--timeout=N] [--interval=N] [--silent]
# Returns 0 on success, 1 on timeout
# Prints progress unless --silent is specified
#
# Requires: cmd, cmd_value, cmd_clean functions from shared.sh
# Requires: Colors (GREEN, RED, YELLOW, NC) from shared.sh
# Default timeout values (can be overridden)
DEFAULT_TIMEOUT=60
DEFAULT_INTERVAL=2
# ============================================================================
# Generic Wait Function
# ============================================================================
# Wait for a condition to be true
# Usage: wait_for_condition "label" "command" "expected_pattern" [--timeout=N] [--interval=N] [--silent]
# The command is run locally (not on a remote node)
wait_for_condition() {
local label="$1"
local command="$2"
local expected="$3"
shift 3
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
# Parse optional arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for $label (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(eval "$command" 2>/dev/null)
if [[ "$result" == *"$expected"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# ============================================================================
# Service Wait Functions
# ============================================================================
# Wait for a systemd service to become active
# Usage: wait_for_service "$node" "service-name" [--timeout=N] [--interval=N] [--silent]
wait_for_service() {
local node="$1"
local service="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for $service to be active (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local status
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'unknown'")
if [[ "$status" == "active" ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}active${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC} (status: $status)"
return 1
}
# Wait for a oneshot service to complete successfully
# Usage: wait_for_service_completed "$node" "service-name" [--timeout=N] [--interval=N] [--silent]
wait_for_service_completed() {
local node="$1"
local service="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for $service to complete (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local status
status=$(cmd_value "$node" "systemctl is-active $service 2>/dev/null || echo 'unknown'")
if [[ "$status" == "inactive" ]]; then
# Check exit status
local exit_status
exit_status=$(cmd_value "$node" "systemctl show -p ExecMainStatus $service --value")
if [[ "$exit_status" == "0" ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}completed${NC} (${elapsed}s)"
return 0
else
[[ "$silent" == false ]] && echo -e " ${RED}failed${NC} (exit: $exit_status)"
return 1
fi
elif [[ "$status" == "failed" ]]; then
[[ "$silent" == false ]] && echo -e " ${RED}failed${NC}"
return 1
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# ============================================================================
# Port Wait Functions
# ============================================================================
# Wait for a port to start listening
# Usage: wait_for_port "$node" "port" [--timeout=N] [--interval=N] [--silent]
wait_for_port() {
local node="$1"
local port="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for port $port to listen (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(cmd "$node" "ss -tlnp 2>/dev/null | grep ':$port '" 2>/dev/null)
if [[ "$result" == *":$port"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}listening${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# Wait for multiple ports to start listening
# Usage: wait_for_ports "$node" "port1 port2 port3" [--timeout=N] [--interval=N]
wait_for_ports() {
local node="$1"
local ports="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
*) ;;
esac
shift
done
for port in $ports; do
if ! wait_for_port "$node" "$port" --timeout="$timeout" --interval="$interval"; then
return 1
fi
done
return 0
}
# ============================================================================
# HTTP Wait Functions
# ============================================================================
# Wait for an HTTP endpoint to respond with expected status code
# Usage: wait_for_http "$node" "url" "expected_codes" [--timeout=N] [--interval=N] [--silent]
# expected_codes can be space-separated: "200 302 303"
wait_for_http() {
local node="$1"
local url="$2"
local expected="${3:-200}"
shift 3
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for HTTP $url (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local code
code=$(cmd_value "$node" "curl -s -o /dev/null -w '%{http_code}' --max-time 5 '$url' 2>/dev/null || echo '000'")
for exp in $expected; do
if [[ "$code" == "$exp" ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}HTTP $code${NC} (${elapsed}s)"
return 0
fi
done
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC} (last: HTTP $code)"
return 1
}
# Wait for an HTTP endpoint to contain expected content
# Usage: wait_for_http_content "$node" "url" "expected_string" [--timeout=N] [--interval=N] [--silent]
wait_for_http_content() {
local node="$1"
local url="$2"
local expected="$3"
shift 3
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for HTTP content '$expected' (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local response
response=$(cmd_clean "$node" "curl -s --max-time 5 '$url' 2>/dev/null")
if [[ "$response" == *"$expected"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}found${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# ============================================================================
# Container Wait Functions
# ============================================================================
# Wait for a podman container to be running
# Usage: wait_for_container "$node" "container-name" [--timeout=N] [--interval=N] [--silent]
wait_for_container() {
local node="$1"
local container="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for container $container (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local status
status=$(cmd_clean "$node" "podman ps --filter name=$container --format '{{.Status}}' 2>/dev/null")
if [[ "$status" == *"Up"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}running${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# ============================================================================
# Database Wait Functions
# ============================================================================
# Wait for PostgreSQL to accept connections
# Usage: wait_for_postgresql "$node" [--timeout=N] [--interval=N] [--silent]
wait_for_postgresql() {
local node="$1"
shift
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for PostgreSQL to accept connections (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(cmd_clean "$node" "sudo -u postgres psql -c 'SELECT 1;' 2>/dev/null")
if [[ "$result" == *"1"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# Wait for Redis to respond to PING
# Usage: wait_for_redis "$node" [port] [--timeout=N] [--interval=N] [--silent]
wait_for_redis() {
local node="$1"
local port="${2:-6379}"
shift 2 2>/dev/null || shift 1
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for Redis on port $port (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(cmd_clean "$node" "redis-cli -p $port PING 2>/dev/null")
if [[ "$result" == *"PONG"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# Wait for MongoDB to accept connections
# Usage: wait_for_mongodb "$node" [port] [--timeout=N] [--interval=N] [--silent]
wait_for_mongodb() {
local node="$1"
local port="${2:-27017}"
shift 2 2>/dev/null || shift 1
local timeout=$DEFAULT_TIMEOUT
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for MongoDB on port $port (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(cmd_clean "$node" "mongosh --port $port --quiet --eval 'db.runCommand({ping:1})' 2>/dev/null")
if [[ "$result" == *"ok"* ]]; then
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# Wait for Elasticsearch/OpenSearch cluster to be ready
# Usage: wait_for_elasticsearch "$node" [port] [--timeout=N] [--interval=N] [--silent]
wait_for_elasticsearch() {
local node="$1"
local port="${2:-9200}"
shift 2 2>/dev/null || shift 1
local timeout=${DEFAULT_TIMEOUT:-60}
local interval=$DEFAULT_INTERVAL
local silent=false
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
--interval=*) interval="${1#*=}" ;;
--silent) silent=true ;;
*) ;;
esac
shift
done
[[ "$silent" == false ]] && echo -n " Waiting for Elasticsearch on port $port (${timeout}s timeout)..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local result
result=$(cmd_clean "$node" "curl -s http://127.0.0.1:$port/_cluster/health 2>/dev/null")
if [[ "$result" == *"cluster_name"* ]]; then
local status
status=$(echo "$result" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[[ "$silent" == false ]] && echo -e " ${GREEN}ready${NC} (status: $status, ${elapsed}s)"
return 0
fi
[[ "$silent" == false ]] && echo -n "."
sleep "$interval"
elapsed=$((elapsed + interval))
done
[[ "$silent" == false ]] && echo -e " ${RED}timeout${NC}"
return 1
}
# ============================================================================
# Composite Wait Functions
# ============================================================================
# Wait for a service and its port to be ready
# Usage: wait_for_service_and_port "$node" "service" "port" [--timeout=N]
wait_for_service_and_port() {
local node="$1"
local service="$2"
local port="$3"
shift 3
local timeout=$DEFAULT_TIMEOUT
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
*) ;;
esac
shift
done
# Split timeout between service and port
local half_timeout=$((timeout / 2))
if ! wait_for_service "$node" "$service" --timeout="$half_timeout"; then
return 1
fi
if ! wait_for_port "$node" "$port" --timeout="$half_timeout"; then
return 1
fi
return 0
}
# Wait for multiple services to be active
# Usage: wait_for_services "$node" "svc1 svc2 svc3" [--timeout=N]
wait_for_services() {
local node="$1"
local services="$2"
shift 2
local timeout=$DEFAULT_TIMEOUT
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout=*) timeout="${1#*=}" ;;
*) ;;
esac
shift
done
for service in $services; do
if ! wait_for_service "$node" "$service" --timeout="$timeout"; then
return 1
fi
done
return 0
}
# ============================================================================
# Timeout Wrapper
# ============================================================================
# Run a command with a timeout (uses bash timeout if available)
# Usage: with_timeout 30 "command to run"
with_timeout() {
local timeout="$1"
shift
local command="$*"
if command -v timeout &> /dev/null; then
timeout "$timeout" bash -c "$command"
else
# Fallback for systems without timeout command
eval "$command" &
local pid=$!
local count=0
while kill -0 $pid 2>/dev/null; do
sleep 1
count=$((count + 1))
if [[ $count -ge $timeout ]]; then
kill -9 $pid 2>/dev/null
return 124 # Same exit code as timeout command
fi
done
wait $pid
return $?
fi
}
@@ -0,0 +1,706 @@
{ config, pkgs, lib, ... }:
let
appName = "beiwe-backend";
defaultPort = 8080;
cfg = config.infrastructure.${appName};
# Build the beiwe-backend package
beiwePackage = if cfg.package != null then cfg.package else
pkgs.callPackage ./package.nix {
rev = cfg.version;
};
# Construct the Celery broker URL from RabbitMQ settings
celeryBrokerUrl = if cfg.celery.enable then
"amqp://${cfg.celery.rabbitmq.user}:${cfg.celery.rabbitmq.password}@${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}/${cfg.celery.rabbitmq.vhost}"
else "";
# Environment variables for beiwe-backend configuration
# See: https://github.com/jhsware/beiwe-backend (fork with env var support)
beiweEnvironment = {
# Required settings
DOMAIN_NAME = cfg.domainName;
FLASK_SECRET_KEY = cfg.flaskSecretKey;
SYSADMIN_EMAILS = cfg.sysadminEmails;
# Database settings (PostgreSQL)
RDS_DB_NAME = cfg.database.name;
RDS_USERNAME = cfg.database.user;
RDS_PASSWORD = cfg.database.password;
RDS_HOSTNAME = cfg.database.host;
RDS_PORT = toString cfg.database.port;
# PostgreSQL SSL mode
# Multiple env vars to ensure compatibility with different Django/psycopg versions
PGSSLMODE = cfg.database.sslmode;
DATABASE_SSLMODE = cfg.database.sslmode;
# S3/MinIO settings
S3_BUCKET = cfg.s3.bucket;
AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
BEIWE_SERVER_AWS_ACCESS_KEY_ID = cfg.s3.accessKeyId;
BEIWE_SERVER_AWS_SECRET_ACCESS_KEY = cfg.s3.secretAccessKey;
S3_ACCESS_CREDENTIALS_USER = cfg.s3.accessKeyId;
S3_ACCESS_CREDENTIALS_KEY = cfg.s3.secretAccessKey;
# Django settings
DJANGO_SETTINGS_MODULE = "config.django_settings";
} // (lib.optionalAttrs (cfg.s3.endpoint != "") {
# Custom S3 endpoint for MinIO
S3_ENDPOINT_URL = cfg.s3.endpoint;
AWS_S3_ENDPOINT_URL = cfg.s3.endpoint;
}) // (lib.optionalAttrs (cfg.sentry.dsn != "") {
# Sentry error tracking (optional)
SENTRY_ELASTIC_BEANSTALK_DSN = cfg.sentry.dsn;
SENTRY_DATA_PROCESSING_DSN = cfg.sentry.dsn;
}) // (lib.optionalAttrs cfg.celery.enable {
# Celery/RabbitMQ settings
CELERY_BROKER_URL = celeryBrokerUrl;
BROKER_URL = celeryBrokerUrl;
# jhsware fork environment variables for Celery configuration
# These replace the manager_ip file requirement
CELERY_MANAGER_IP = "${cfg.celery.rabbitmq.host}:${toString cfg.celery.rabbitmq.port}";
CELERY_PASSWORD = cfg.celery.rabbitmq.password;
}) // cfg.extraEnvironment;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.beiwe-backend";
# ==========================================================================
# Package and Version Configuration
# ==========================================================================
version = lib.mkOption {
type = lib.types.str;
description = ''
Git commit hash of beiwe-backend to install.
Uses jhsware fork which adds environment variable support for Celery.
Supported versions are defined in package.nix.
See package.nix for instructions on adding new versions.
'';
default = "93be878"; # jhsware fork with CELERY_MANAGER_IP/CELERY_PASSWORD env var support
example = "main";
};
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
Custom beiwe-backend package to use. If null, the package will be built
using the version specified in 'version' option.
'';
default = null;
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind beiwe-backend to.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for beiwe-backend web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for beiwe-backend.";
default = false;
};
domainName = lib.mkOption {
type = lib.types.str;
description = "Domain name for the Beiwe backend (used in DOMAIN_NAME env var).";
default = "localhost:8080";
example = "beiwe.example.com";
};
# ==========================================================================
# Security Configuration
# ==========================================================================
flaskSecretKey = lib.mkOption {
type = lib.types.str;
description = ''
A unique, cryptographically secure string for Flask sessions.
IMPORTANT: Change this in production!
'';
default = "CHANGE_ME_IN_PRODUCTION_use_a_random_string";
example = "your-super-secret-random-key-here";
};
sysadminEmails = lib.mkOption {
type = lib.types.str;
description = "System administrator email addresses (comma-separated).";
default = "sysadmin@localhost";
example = "admin@example.com";
};
# ==========================================================================
# Data Directory
# ==========================================================================
dataDir = lib.mkOption {
type = lib.types.path;
description = "Directory where beiwe-backend data is stored.";
default = "/var/lib/beiwe-backend";
};
# ==========================================================================
# Database Configuration (PostgreSQL)
# ==========================================================================
database = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host.";
default = "/run/postgresql";
example = "localhost";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
name = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "beiwe";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "beiwe";
};
password = lib.mkOption {
type = lib.types.str;
description = ''
PostgreSQL password. Required by Beiwe even when using trust authentication.
For trust authentication, use an empty string or placeholder value.
'';
default = "unused_with_trust_auth";
example = "secure-password-here";
};
passwordSecretName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the secret containing the PostgreSQL password.
The secret should be placed at /run/secrets/<n>.
If null, peer/socket authentication is assumed.
'';
default = null;
example = "beiwe-db-password";
};
sslmode = lib.mkOption {
type = lib.types.enum [ "disable" "allow" "prefer" "require" "verify-ca" "verify-full" ];
description = ''
PostgreSQL SSL mode. For local development without SSL certificates,
use "disable". For production with SSL, use "require" or "verify-full".
See: https://www.postgresql.org/docs/current/libpq-ssl.html
'';
default = "prefer";
example = "disable";
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database user locally.
This requires PostgreSQL to be running locally with trust or peer authentication.
'';
default = false;
};
};
# ==========================================================================
# S3/MinIO Configuration
# ==========================================================================
s3 = {
bucket = lib.mkOption {
type = lib.types.str;
description = "S3 bucket name for data storage.";
default = "beiwe-data";
};
accessKeyId = lib.mkOption {
type = lib.types.str;
description = "AWS/MinIO access key ID.";
default = "";
example = "minioadmin";
};
secretAccessKey = lib.mkOption {
type = lib.types.str;
description = "AWS/MinIO secret access key.";
default = "";
example = "minioadmin";
};
endpoint = lib.mkOption {
type = lib.types.str;
description = ''
Custom S3 endpoint URL for MinIO or other S3-compatible storage.
Leave empty for AWS S3.
'';
default = "";
example = "http://localhost:9000";
};
};
# ==========================================================================
# Sentry Configuration (Optional)
# ==========================================================================
sentry = {
dsn = lib.mkOption {
type = lib.types.str;
description = "Sentry DSN for error tracking. Leave empty to disable.";
default = "";
example = "https://xxx@sentry.io/xxx";
};
};
# ==========================================================================
# Celery Configuration (Optional - for background tasks)
# ==========================================================================
celery = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Celery worker for background task processing.
When enabled, the following features become available:
- Push notifications to mobile apps
- Data processing pipelines
- Forest analysis integration
Requires RabbitMQ to be running and accessible.
'';
default = false;
};
rabbitmq = {
host = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ host for Celery broker.";
default = "127.0.0.1";
example = "rabbitmq.example.com";
};
port = lib.mkOption {
type = lib.types.int;
description = "RabbitMQ port.";
default = 5672;
};
user = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ user.";
default = "guest";
example = "beiwe";
};
password = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ password.";
default = "guest";
example = "secure-password";
};
vhost = lib.mkOption {
type = lib.types.str;
description = "RabbitMQ virtual host.";
default = "";
example = "beiwe";
};
};
concurrency = lib.mkOption {
type = lib.types.int;
description = "Number of concurrent Celery worker processes.";
default = 2;
};
queues = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Celery queues to process. Beiwe uses separate queues for different tasks:
- celery (default queue)
- data_processing
- push_notifications
- forest
'';
default = [ "celery" "data_processing" "push_notifications" "forest" ];
};
logLevel = lib.mkOption {
type = lib.types.enum [ "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" ];
description = "Celery worker log level.";
default = "INFO";
};
};
# ==========================================================================
# Gunicorn Configuration
# ==========================================================================
gunicorn = {
workers = lib.mkOption {
type = lib.types.int;
description = "Number of Gunicorn worker processes.";
default = 4;
};
threads = lib.mkOption {
type = lib.types.int;
description = "Number of threads per worker.";
default = 2;
};
timeout = lib.mkOption {
type = lib.types.int;
description = "Request timeout in seconds.";
default = 120;
};
};
# ==========================================================================
# Extra Environment Variables
# ==========================================================================
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = ''
Additional environment variables for beiwe-backend.
These are passed directly to the service.
'';
default = {};
example = lib.literalExpression ''
{
DEBUG = "false";
}
'';
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for beiwe-backend.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "beiwe.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Beiwe User and Group
# ==========================================================================
users.users.beiwe = {
isSystemUser = true;
group = "beiwe";
home = cfg.dataDir;
createHome = true;
description = "Beiwe backend service user";
};
users.groups.beiwe = {};
# ==========================================================================
# Beiwe Backend Systemd Service (Web Server)
# ==========================================================================
systemd.services.beiwe-backend = {
description = "Beiwe Backend - Digital Phenotyping Research Platform";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" ] ++
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
lib.optionals cfg.celery.enable [ "rabbitmq.service" ];
wants = lib.optionals cfg.database.createLocally [
"beiwe-db-setup.service"
];
requires = lib.optionals cfg.database.createLocally [
"postgresql.service"
];
environment = beiweEnvironment;
# Load database password from secret file if specified
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
ExecStartPre = let
preStartScript = pkgs.writeShellScript "beiwe-pre-start" ''
# Run database migrations
${beiwePackage}/bin/beiwe-manage migrate --noinput || true
'';
in "+${preStartScript}";
ExecStart = ''
${beiwePackage}/bin/beiwe-gunicorn wsgi:application \
--bind ${cfg.bindToIp}:${toString cfg.bindToPort} \
--workers ${toString cfg.gunicorn.workers} \
--threads ${toString cfg.gunicorn.threads} \
--timeout ${toString cfg.gunicorn.timeout} \
--access-logfile - \
--error-logfile -
'';
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# Beiwe Celery Worker Service (Background Task Processing)
# ==========================================================================
#
# Uses jhsware fork which supports CELERY_MANAGER_IP and CELERY_PASSWORD
# environment variables instead of requiring a manager_ip file.
systemd.services.beiwe-celery-worker = lib.mkIf cfg.celery.enable {
description = "Beiwe Celery Worker - Background Task Processing";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" "rabbitmq.service" ];
requires = [ "rabbitmq.service" ];
wants = [ "beiwe-backend.service" ];
environment = beiweEnvironment;
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
# Celery command pattern from beiwe-backend wiki:
# python3 -m celery -A services.celery_data_processing worker -Q ...
ExecStart = let
queuesArg = lib.concatStringsSep "," cfg.celery.queues;
in ''
${beiwePackage}/bin/beiwe-celery \
-A services.celery_data_processing \
worker \
--queues=${queuesArg} \
--concurrency=${toString cfg.celery.concurrency} \
--loglevel=${cfg.celery.logLevel}
'';
Restart = "on-failure";
RestartSec = "10s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir "/tmp" ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# Beiwe Celery Beat Service (Scheduled Tasks)
# ==========================================================================
systemd.services.beiwe-celery-beat = lib.mkIf cfg.celery.enable {
description = "Beiwe Celery Beat - Task Scheduler";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "rabbitmq.service" "beiwe-celery-worker.service" ];
requires = [ "rabbitmq.service" ];
wants = [ "beiwe-celery-worker.service" ];
environment = beiweEnvironment;
serviceConfig = {
Type = "simple";
User = "beiwe";
Group = "beiwe";
WorkingDirectory = "${beiwePackage}/lib/beiwe-backend";
ExecStart = ''
${beiwePackage}/bin/beiwe-celery \
-A services.celery_data_processing \
beat \
--loglevel=${cfg.celery.logLevel} \
--schedule=${cfg.dataDir}/celerybeat-schedule
'';
Restart = "on-failure";
RestartSec = "10s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
} // (lib.optionalAttrs (cfg.database.passwordSecretName != null) {
EnvironmentFile = "/run/secrets/${cfg.database.passwordSecretName}";
});
};
# ==========================================================================
# PostgreSQL Database Setup (Optional)
# ==========================================================================
systemd.services.beiwe-db-setup = lib.mkIf cfg.database.createLocally {
description = "Create Beiwe database and user";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
before = [ "beiwe-backend.service" ];
requiredBy = [ "beiwe-backend.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
};
script = let
dbUser = cfg.database.user;
dbName = cfg.database.name;
dbHost = cfg.database.host;
dbPort = toString cfg.database.port;
in ''
set -euo pipefail
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort} 2>/dev/null; do
sleep 1
done
echo "PostgreSQL is ready"
# Create database user if it doesn't exist
echo "Checking if user '${dbUser}' exists..."
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1; then
echo "Creating user '${dbUser}'..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
else
echo "User '${dbUser}' already exists"
fi
# Create database if it doesn't exist
echo "Checking if database '${dbName}' exists..."
if ! ${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -tAc "SELECT 1 FROM pg_database WHERE datname='${dbName}'" | grep -q 1; then
echo "Creating database '${dbName}'..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE DATABASE ${dbName} OWNER ${dbUser}"
else
echo "Database '${dbName}' already exists"
fi
# Grant privileges on database (idempotent)
echo "Granting privileges..."
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}" || true
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}" || true
echo "Database setup complete"
'';
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
locations."/" = {
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout ${toString cfg.gunicorn.timeout}s;
proxy_connect_timeout ${toString cfg.gunicorn.timeout}s;
client_max_body_size 100M;
'';
};
# Static files
locations."/static/" = {
alias = "${beiwePackage}/lib/beiwe-backend/frontend/static/";
extraConfig = ''
expires 30d;
add_header Cache-Control "public, immutable";
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = [
beiwePackage
pkgs.curl
pkgs.jq
];
};
}
@@ -0,0 +1,309 @@
# Beiwe Backend package
# A Django-based smartphone digital phenotyping research platform backend
#
# Using jhsware fork which adds environment variable support for Celery configuration
# (CELERY_MANAGER_IP and CELERY_PASSWORD instead of manager_ip file)
#
# To update to a new version:
# 1. Update the rev to the new commit hash
# 2. Run: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<NEW_COMMIT>.tar.gz
# 3. Update the hash with the output from step 2
{
lib,
stdenv,
fetchFromGitHub,
fetchPypi,
python312,
python312Packages,
postgresql,
# Custom parameters
rev ? "93be878", # jhsware fork with env var support for Celery
}:
let
# Known version hashes
# To add a new version, run:
# nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/<COMMIT>.tar.gz
versionHashes = {
# jhsware fork with CELERY_MANAGER_IP and CELERY_PASSWORD env var support
"93be878" = {
srcHash = "sha256-marYxVINxgW0X9x+xoHL7bdRYEgm+4q+O1CF5WXeZGg=";
};
# Original onnela-lab version (for reference)
"6bb5363" = {
srcHash = "sha256-EeD+I3mWC81mhmlO9cKzRrArDLKVBmqhZjgJI8+geu0=";
};
};
hashes = versionHashes.${rev} or (throw ''
beiwe-backend revision ${rev} is not supported.
Supported revisions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
To add support for revision ${rev}:
1. Get source hash: nix-prefetch-url --unpack https://github.com/jhsware/beiwe-backend/archive/${rev}.tar.gz
2. Add entry to versionHashes in app_modules/_unstable/beiwe-backend/package.nix
'');
# Build cronutils from PyPI (not available in nixpkgs)
cronutils = python312Packages.buildPythonPackage rec {
pname = "cronutils";
version = "0.4.2";
format = "setuptools";
src = fetchPypi {
inherit pname version;
hash = "sha256-SFHkQ9NltAyWArArkFpSBIJF3gMoXbxHEXreM1SEPUY=";
};
propagatedBuildInputs = with python312Packages; [
sentry-sdk
];
# Tests require network access
doCheck = false;
pythonImportsCheck = [ "cronutils" ];
meta = with lib; {
description = "Utilities for cron jobs including error handling";
homepage = "https://pypi.org/project/cronutils/";
license = licenses.mit;
};
};
# Build beiwe-forest from GitHub (Forest analysis library)
# See: https://github.com/onnela-lab/forest
# Note: pip install git+https://github.com/onnela-lab/forest
beiweForest = python312Packages.buildPythonPackage rec {
pname = "forest";
version = "unstable-2024-12-01";
format = "pyproject";
src = fetchFromGitHub {
owner = "onnela-lab";
repo = "forest";
rev = "develop"; # Main development branch
hash = "sha256-t+oq/jfJUmWCs0XzrN+xciYc3lz4oPiO8V8qfj3iTJA=";
};
nativeBuildInputs = with python312Packages; [
setuptools
];
propagatedBuildInputs = with python312Packages; [
# Core data science
numpy
pandas
scipy
scikit-learn
# Time/date utilities
pytz
holidays
timezonefinder
# GIS/mapping
shapely
pyproj
# Audio processing (for voice analysis)
librosa
# HTTP/API
requests
ratelimit
];
# Some optional dependencies not in nixpkgs (openrouteservice, ssqueezepy)
# Disable strict runtime deps check to allow partial functionality
pythonRelaxDeps = true;
pythonRemoveDeps = [ "openrouteservice" "ssqueezepy" ];
# Tests require data files
doCheck = false;
pythonImportsCheck = [ "forest" ];
meta = with lib; {
description = "Forest library for analyzing Beiwe digital phenotyping data";
homepage = "https://github.com/onnela-lab/forest";
license = licenses.bsd3;
};
};
# Python environment with all dependencies from requirements.txt
pythonEnv = python312.withPackages (ps: with ps; [
# Django and web framework
django
django-extensions
django-timezone-field # Provides timezone_field module
gunicorn
jinja2
# Database - using psycopg (v3) as specified in requirements.txt
psycopg
# AWS/S3 support
boto3
# Celery for task queue
celery
# Error tracking and monitoring
sentry-sdk
cronutils # Custom package built above
# Firebase (push notifications)
firebase-admin
# Security and crypto
pycryptodomex # Note: pycryptodomex not pycryptodome
pyotp
bleach
# Serialization
orjson
# Date/time utilities
python-dateutil
pytz
# Compression - pyzstd provides "import pyzstd" (jhsware fork uses pyzstd)
pyzstd
# Data analysis
numpy
pandas
scipy
scikit-learn
beiweForest # Custom package - provides "import forest"
# Other utilities
requests
rcssmin
pypng
pyqrcode
# Development/debugging
ipython
mypy
]);
in
stdenv.mkDerivation {
pname = "beiwe-backend";
version = rev;
src = fetchFromGitHub {
owner = "jhsware"; # Fork with env var support for Celery
repo = "beiwe-backend";
inherit rev;
hash = hashes.srcHash;
};
buildInputs = [
pythonEnv
postgresql
];
# No build phase needed - this is a Python application
dontBuild = true;
# Patch Django settings to support DATABASE_SSLMODE environment variable
# This allows controlling PostgreSQL SSL mode via environment variable
postPatch = ''
# Find the Django settings file and patch the database configuration
# to include sslmode from environment variable
# Add sslmode support to database configuration
# This sed command finds the DATABASES dict and adds OPTIONS with sslmode
if [ -f config/django_settings.py ]; then
echo "Patching config/django_settings.py for DATABASE_SSLMODE support..."
# Add import for os at the top if not already there
if ! grep -q "^import os" config/django_settings.py; then
sed -i '1i import os' config/django_settings.py
fi
# Append code to add sslmode to database options at the end of the file
cat >> config/django_settings.py << 'SSLPATCH'
# Patched by nix-infra-machine: Add DATABASE_SSLMODE support
# This allows setting PostgreSQL sslmode via environment variable
_db_sslmode = os.environ.get('DATABASE_SSLMODE', os.environ.get('PGSSLMODE', 'prefer'))
if 'default' in DATABASES:
if 'OPTIONS' not in DATABASES['default']:
DATABASES['default']['OPTIONS'] = {}
DATABASES['default']['OPTIONS']['sslmode'] = _db_sslmode
SSLPATCH
echo "Patched database settings for sslmode support"
else
echo "Warning: config/django_settings.py not found, skipping sslmode patch"
fi
'';
installPhase = ''
runHook preInstall
# Create directory structure
mkdir -p $out/lib/beiwe-backend
mkdir -p $out/bin
# Copy all source files
cp -r . $out/lib/beiwe-backend/
# Create wrapper scripts
cat > $out/bin/beiwe-manage <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/python manage.py "\$@"
EOF
chmod +x $out/bin/beiwe-manage
cat > $out/bin/beiwe-gunicorn <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/gunicorn "\$@"
EOF
chmod +x $out/bin/beiwe-gunicorn
cat > $out/bin/beiwe-celery <<EOF
#!/usr/bin/env bash
cd $out/lib/beiwe-backend
exec ${pythonEnv}/bin/celery "\$@"
EOF
chmod +x $out/bin/beiwe-celery
runHook postInstall
'';
meta = {
description = "Beiwe - smartphone-based digital phenotyping research platform backend";
longDescription = ''
The Beiwe Research Platform collects high-throughput smartphone-based
digital phenotyping data including spatial trajectories (GPS), physical
activity patterns (accelerometer/gyroscope), social networks and
communication dynamics (call/text logs), and voice samples.
This package provides the Django-based backend server that supports:
- Web-based study management portal
- API endpoints for iOS/Android mobile apps
- Data processing pipelines
This is the jhsware fork which adds environment variable support for
Celery configuration (CELERY_MANAGER_IP and CELERY_PASSWORD).
Patched by nix-infra-machine to support DATABASE_SSLMODE environment
variable for controlling PostgreSQL SSL mode.
'';
homepage = "https://github.com/jhsware/beiwe-backend";
license = lib.licenses.bsd3;
platforms = lib.platforms.unix;
};
}
@@ -0,0 +1,318 @@
# CrowdSec Firewall Bouncer Module
# Provides firewall-level IP blocking using iptables/nftables/ipset
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
features.firewallBouncer = lib.mkOption {
type = lib.types.bool;
description = ''
Enable the firewall bouncer to automatically block malicious IPs.
The bouncer fetches decisions from the CrowdSec API and applies
them to the system firewall (iptables/nftables). Available in
nixpkgs as pkgs.crowdsec-firewall-bouncer starting from NixOS 25.11.
When using nftables mode (default), the module creates declarative
nftables tables that integrate properly with NixOS's firewall and
survive system rebuilds.
[NIS2 COMPLIANCE]
Article 21(2)(b) - Incident Handling: Provides automated incident
response by blocking identified threats in real-time.
Article 21(2)(d) - Network Security: Implements active network
protection through automated firewall rule management.
'';
default = false;
};
bouncer = {
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
CrowdSec firewall bouncer package to use.
The package is available in nixpkgs as pkgs.crowdsec-firewall-bouncer
starting from NixOS 25.11.
Set to null to disable the bouncer even when features.firewallBouncer
is enabled (useful for testing detection without blocking).
'';
default = pkgs.crowdsec-firewall-bouncer or null;
defaultText = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
example = lib.literalExpression "pkgs.crowdsec-firewall-bouncer";
};
mode = lib.mkOption {
type = lib.types.enum [ "iptables" "nftables" "ipset" ];
description = ''
Firewall mode for the bouncer.
- "nftables": Recommended for NixOS. Uses nftables sets which integrate
well with NixOS declarative firewall. The module creates the necessary
tables/chains declaratively, and the bouncer only manages set membership.
- "iptables": Traditional iptables rules. May conflict with NixOS firewall
on system rebuilds.
- "ipset": Uses ipset for IP blocking. More compatible with iptables-based
firewalls and survives rule flushes better.
[NIS2 COMPLIANCE]
All modes provide equivalent security protection. Choose based on your
existing firewall infrastructure.
'';
default = "nftables";
};
nftablesIntegration = lib.mkOption {
type = lib.types.bool;
description = ''
When using nftables mode, declaratively create the CrowdSec table
structure in NixOS configuration. This ensures the tables/chains
survive NixOS rebuilds and prevents conflicts with the declarative
firewall.
When enabled:
- Creates "crowdsec" and "crowdsec6" tables declaratively
- Configures bouncer in "set-only" mode
- Bouncer only manages IP set membership, not table structure
When disabled:
- Bouncer creates and manages its own tables
- May conflict with NixOS firewall rebuilds
'';
default = true;
};
denyAction = lib.mkOption {
type = lib.types.enum [ "DROP" "REJECT" ];
description = ''
Action to take for blocked IPs.
- "DROP": Silently drop packets (recommended for security)
- "REJECT": Send rejection response to client
DROP is generally preferred as it doesn't reveal firewall presence.
'';
default = "DROP";
};
denyLog = lib.mkOption {
type = lib.types.bool;
description = ''
Log blocked connections before dropping/rejecting.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Maintains audit trail
of blocked threats for incident analysis and reporting.
'';
default = true;
};
denyLogPrefix = lib.mkOption {
type = lib.types.str;
description = "Prefix for firewall log entries.";
default = "crowdsec: ";
};
banDuration = lib.mkOption {
type = lib.types.str;
description = ''
Default ban duration for blocked IPs.
Format: Go duration string (e.g., "4h", "24h", "7d")
'';
default = "4h";
example = "24h";
};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.features.firewallBouncer && cfg.bouncer.package != null) (
let
useNftablesIntegration = cfg.bouncer.mode == "nftables" && cfg.bouncer.nftablesIntegration;
# Bouncer config - uses set-only mode when nftablesIntegration is enabled
bouncerConfigFile = yamlFormat.generate "crowdsec-firewall-bouncer.yaml" ({
mode = cfg.bouncer.mode;
update_frequency = "10s";
api_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/";
api_key = "\${BOUNCER_API_KEY}";
disable_ipv6 = false;
deny_action = cfg.bouncer.denyAction;
deny_log = cfg.bouncer.denyLog;
deny_log_prefix = cfg.bouncer.denyLogPrefix;
} // lib.optionalAttrs (cfg.bouncer.mode == "nftables") {
nftables = {
ipv4 = {
enabled = true;
set-only = useNftablesIntegration;
table = "crowdsec";
chain = "crowdsec-chain";
set = "crowdsec-blocklist";
};
ipv6 = {
enabled = true;
set-only = useNftablesIntegration;
table = "crowdsec6";
chain = "crowdsec6-chain";
set = "crowdsec6-blocklist";
};
};
} // lib.optionalAttrs (cfg.bouncer.mode == "iptables") {
iptables_chains = [ "INPUT" "FORWARD" ];
} // lib.optionalAttrs (cfg.bouncer.mode == "ipset") {
ipset_type = "nethash";
ipset = "crowdsec-blocklist";
ipset6 = "crowdsec6-blocklist";
});
bouncerRegisterScript = pkgs.writeShellScript "crowdsec-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="/var/lib/crowdsec-firewall-bouncer/api_key"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "firewall-bouncer"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add firewall-bouncer -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
fi
# Read existing key
if [ -f "$KEY_FILE" ]; then
export BOUNCER_API_KEY=$(cat "$KEY_FILE")
fi
# Generate config with key substituted
# Use | as sed delimiter since API keys may contain /
if [ -n "$BOUNCER_API_KEY" ]; then
sed "s|\''${BOUNCER_API_KEY}|$BOUNCER_API_KEY|g" ${bouncerConfigFile} > /var/lib/crowdsec-firewall-bouncer/config.yaml
fi
'';
in lib.mkMerge [
# Assertions
{
assertions = [
{
assertion = cfg.bouncer.package != null;
message = ''
CrowdSec firewall bouncer is enabled but no package is configured.
The bouncer package should be available as pkgs.crowdsec-firewall-bouncer
on NixOS 25.11+. If using an older NixOS version, you may need to:
1. Upgrade to NixOS 25.11+
2. Set infrastructure.crowdsec.features.firewallBouncer = false
3. Provide the package from an external source
'';
}
];
# Install CLI tools based on mode
environment.systemPackages =
lib.optionals (cfg.bouncer.mode == "nftables") [ pkgs.nftables ]
++ lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables ]
++ lib.optionals (cfg.bouncer.mode == "ipset") [ pkgs.ipset ];
}
# Declarative nftables Integration
(lib.mkIf useNftablesIntegration {
networking.nftables.enable = true;
networking.nftables.tables = {
# IPv4 CrowdSec table
crowdsec = {
family = "ip";
content = ''
set crowdsec-blocklist {
type ipv4_addr
flags timeout
}
chain crowdsec-chain {
type filter hook input priority -1; policy accept;
${lib.optionalString cfg.bouncer.denyLog ''
ip saddr @crowdsec-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
''}
ip saddr @crowdsec-blocklist ${lib.toLower cfg.bouncer.denyAction}
}
'';
};
# IPv6 CrowdSec table
crowdsec6 = {
family = "ip6";
content = ''
set crowdsec6-blocklist {
type ipv6_addr
flags timeout
}
chain crowdsec6-chain {
type filter hook input priority -1; policy accept;
${lib.optionalString cfg.bouncer.denyLog ''
ip6 saddr @crowdsec6-blocklist log prefix "${cfg.bouncer.denyLogPrefix}"
''}
ip6 saddr @crowdsec6-blocklist ${lib.toLower cfg.bouncer.denyAction}
}
'';
};
};
})
# Tmpfiles and service
{
systemd.tmpfiles.rules = [
"d /var/lib/crowdsec-firewall-bouncer 0750 root root - -"
];
# Firewall bouncer service
systemd.services.crowdsec-firewall-bouncer = {
description = "CrowdSec Firewall Bouncer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
path = lib.optionals (cfg.bouncer.mode == "iptables") [ pkgs.iptables pkgs.ipset ];
serviceConfig = {
Type = "simple";
ExecStartPre = "${bouncerRegisterScript}";
ExecStart = "${cfg.bouncer.package}/bin/cs-firewall-bouncer -c /var/lib/crowdsec-firewall-bouncer/config.yaml";
Restart = "always";
RestartSec = "10s";
};
};
}
]
);
}
@@ -0,0 +1,181 @@
# CrowdSec HAProxy SPOA Bouncer Module
# Provides application-layer protection via HAProxy Stream Processing Offload API
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
features.haproxyProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable HAProxy security integration via SPOA (Stream Processing Offload API).
The cs-haproxy-spoa-bouncer acts as a stream processing agent that checks
each connection in real-time against CrowdSec's decision database before
allowing traffic to reach your application servers.
This provides layer 7 application-level protection, complementing the
layer 3/4 protection from the firewall bouncer.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides application-layer protection
for HTTP/HTTPS traffic through HAProxy integration.
Article 21(2)(e) - Supply Chain Security: Protects web applications that
may be part of the digital supply chain.
'';
default = false;
};
haproxy = {
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
CrowdSec HAProxy SPOA bouncer package to use.
The package should be available as pkgs.cs-haproxy-spoa-bouncer.
Set to null to disable the bouncer even when features.haproxyProtection
is enabled.
'';
default = pkgs.cs-haproxy-spoa-bouncer or null;
defaultText = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
example = lib.literalExpression "pkgs.cs-haproxy-spoa-bouncer";
};
listenAddr = lib.mkOption {
type = lib.types.str;
description = ''
Address for the SPOA bouncer to listen on.
HAProxy will connect to this address to check decisions.
'';
default = "127.0.0.1";
example = "0.0.0.0";
};
listenPort = lib.mkOption {
type = lib.types.port;
description = "Port for the SPOA bouncer to listen on.";
default = 3000;
example = 3000;
};
action = lib.mkOption {
type = lib.types.enum [ "deny" "tarpit" ];
description = ''
Action to take for blocked requests in HAProxy.
- "deny": Immediately reject the connection
- "tarpit": Slow down the connection (tar pit)
'';
default = "deny";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "error" "warning" "info" "debug" ];
description = "Log level for the SPOA bouncer.";
default = "info";
};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.features.haproxyProtection && cfg.haproxy.package != null) (
let
# HAProxy SPOA bouncer config file
haproxyBouncerConfigFile = yamlFormat.generate "crowdsec-haproxy-spoa-bouncer.yaml" {
lapi_url = "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
lapi_key = "\${HAPROXY_SPOA_API_KEY}";
action = cfg.haproxy.action;
log_level = cfg.haproxy.logLevel;
listen_addr = cfg.haproxy.listenAddr;
listen_port = cfg.haproxy.listenPort;
update_frequency = "10s";
};
haproxyBouncerRegisterScript = pkgs.writeShellScript "crowdsec-haproxy-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="/var/lib/crowdsec-haproxy-bouncer/api_key"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "haproxy-spoa-bouncer"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add haproxy-spoa-bouncer -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
fi
fi
# Read existing key
if [ -f "$KEY_FILE" ]; then
export HAPROXY_SPOA_API_KEY=$(cat "$KEY_FILE")
fi
# Generate config with key substituted
# Use | as sed delimiter since API keys may contain /
if [ -n "$HAPROXY_SPOA_API_KEY" ]; then
sed "s|\''${HAPROXY_SPOA_API_KEY}|$HAPROXY_SPOA_API_KEY|g" ${haproxyBouncerConfigFile} > /var/lib/crowdsec-haproxy-bouncer/config.yaml
fi
'';
in {
# Assertions
assertions = [
{
assertion = cfg.haproxy.package != null;
message = ''
CrowdSec HAProxy SPOA bouncer is enabled but no package is configured.
The bouncer package should be available as pkgs.cs-haproxy-spoa-bouncer.
If the package is not available, you may need to:
1. Set infrastructure.crowdsec.features.haproxyProtection = false
2. Provide the package from an external source
'';
}
];
# Tmpfiles
systemd.tmpfiles.rules = [
"d /var/lib/crowdsec-haproxy-bouncer 0750 root root - -"
];
# HAProxy SPOA bouncer service
systemd.services.crowdsec-haproxy-bouncer = {
description = "CrowdSec HAProxy SPOA Bouncer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "simple";
ExecStartPre = "${haproxyBouncerRegisterScript}";
ExecStart = "${cfg.haproxy.package}/bin/cs-haproxy-spoa-bouncer -c /var/lib/crowdsec-haproxy-bouncer/config.yaml";
Restart = "always";
RestartSec = "10s";
};
};
}
);
}
@@ -0,0 +1,313 @@
# CrowdSec Python Bouncer Module
# Provides API key registration for pycrowdsec and python-capi-sdk integration
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
stateDir = "/var/lib/crowdsec";
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.python = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Python bouncer registration for use with pycrowdsec.
This registers a bouncer with CrowdSec and stores the API key in a
configurable location so Python applications can use the pycrowdsec
library to check IPs against CrowdSec decisions.
Python applications should use the StreamClient or QueryClient from
pycrowdsec to query decisions:
```python
from pycrowdsec.client import StreamClient
client = StreamClient(
api_key=open("/run/crowdsec-python-bouncer/api_key").read().strip(),
lapi_url="http://127.0.0.1:8080/"
)
client.run()
action = client.get_action_for("1.2.3.4") # Returns "ban", "captcha", etc.
```
For Flask/Django integration, configure the middleware to read the API key
from the configured apiKeyFile path.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides application-layer protection
for Python web applications through CrowdSec integration.
'';
default = false;
};
bouncerName = lib.mkOption {
type = lib.types.str;
description = "Name to register the Python bouncer with in CrowdSec.";
default = "python-bouncer";
example = "my-flask-app-bouncer";
};
apiKeyFile = lib.mkOption {
type = lib.types.str;
description = ''
Path where the bouncer API key will be stored.
This file will be readable by the configured group (default: root).
Python applications need read access to this file to authenticate
with the CrowdSec Local API.
'';
default = "/run/crowdsec-python-bouncer/api_key";
example = "/run/secrets/crowdsec-python-api-key";
};
apiKeyFileGroup = lib.mkOption {
type = lib.types.str;
description = ''
Group that should have read access to the API key file.
Set this to match the group your Python application runs as.
For example, if your Flask app runs as user "flask" in group "flask",
set this to "flask".
'';
default = "root";
example = "www-data";
};
enableCapi = lib.mkOption {
type = lib.types.bool;
description = ''
Enable Central API (CAPI) credentials for signal sharing with python-capi-sdk.
When enabled, this generates machine credentials that can be used with
the python-capi-sdk to send attack signals to CrowdSec's central infrastructure
and receive community blocklists.
The credentials are stored in the capiCredentialsFile location.
Example usage with python-capi-sdk:
```python
from cscapi.client import CAPIClient, CAPIClientConfig
from cscapi.sql_storage import SQLStorage
import yaml
# Load credentials generated by this module
with open("/run/crowdsec-python-bouncer/capi_credentials.yaml") as f:
creds = yaml.safe_load(f)
client = CAPIClient(
storage=SQLStorage(connection_string="sqlite:///signals.db"),
config=CAPIClientConfig(scenarios=["crowdsecurity/ssh-bf"])
)
```
[NIS2 COMPLIANCE]
Article 14 - Information Sharing: Enables participation in threat
intelligence sharing through the CrowdSec community network.
'';
default = false;
};
capiCredentialsFile = lib.mkOption {
type = lib.types.str;
description = ''
Path where CAPI credentials will be stored for python-capi-sdk.
This file contains machine_id and password for authenticating
with CrowdSec's Central API.
'';
default = "/run/crowdsec-python-bouncer/capi_credentials.yaml";
example = "/run/secrets/crowdsec-capi-credentials.yaml";
};
capiScenarios = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Scenarios that your Python application will report signals for.
These should match the attack patterns your application detects.
Common scenarios include:
- crowdsecurity/ssh-bf (SSH brute force)
- crowdsecurity/http-bf (HTTP brute force)
- crowdsecurity/http-crawl-non_statics (Web crawling)
'';
default = [];
example = [ "crowdsecurity/http-bf" "crowdsecurity/http-crawl-non_statics" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.python.enable) (
let
# Get the directory from the apiKeyFile path
pythonBouncerDir = builtins.dirOf cfg.python.apiKeyFile;
pythonCapiDir = builtins.dirOf cfg.python.capiCredentialsFile;
# Python bouncer registration script
pythonBouncerRegisterScript = pkgs.writeShellScript "crowdsec-python-bouncer-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
KEY_FILE="${cfg.python.apiKeyFile}"
KEY_DIR="${pythonBouncerDir}"
BOUNCER_NAME="${cfg.python.bouncerName}"
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
# Create directory if it doesn't exist
mkdir -p "$KEY_DIR"
# Wait for CrowdSec API to be ready
for i in $(seq 1 60); do
if cscli -c "$CONFIG_DIR/config.yaml" bouncers list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if bouncer already registered
if ! cscli -c "$CONFIG_DIR/config.yaml" bouncers list 2>/dev/null | grep -q "$BOUNCER_NAME"; then
# Register new bouncer and save key
KEY=$(cscli -c "$CONFIG_DIR/config.yaml" bouncers add "$BOUNCER_NAME" -o raw 2>/dev/null || echo "")
if [ -n "$KEY" ]; then
echo "$KEY" > "$KEY_FILE"
# Set permissions: owner read/write, group read
chmod 640 "$KEY_FILE"
chown root:"$KEY_GROUP" "$KEY_FILE"
echo "Python bouncer '$BOUNCER_NAME' registered successfully"
echo "API key stored at: $KEY_FILE"
fi
else
echo "Python bouncer '$BOUNCER_NAME' already registered"
fi
# Create a helper config file for Python applications
LAPI_URL="http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
cat > "$KEY_DIR/config.yaml" << EOF
# CrowdSec Python Bouncer Configuration
# Generated by NixOS infrastructure.crowdsec module
#
# Usage with pycrowdsec:
# from pycrowdsec.client import StreamClient
# import yaml
#
# with open('${pythonBouncerDir}/config.yaml') as f:
# config = yaml.safe_load(f)
#
# client = StreamClient(
# api_key=open(config['api_key_file']).read().strip(),
# lapi_url=config['lapi_url']
# )
# client.run()
lapi_url: "$LAPI_URL"
api_key_file: "$KEY_FILE"
bouncer_name: "$BOUNCER_NAME"
EOF
chmod 644 "$KEY_DIR/config.yaml"
chown root:"$KEY_GROUP" "$KEY_DIR/config.yaml"
'';
# Python CAPI credentials script (for python-capi-sdk signal sharing)
pythonCapiRegisterScript = pkgs.writeShellScript "crowdsec-python-capi-register" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.gnused pkgs.openssl ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
CAPI_FILE="${cfg.python.capiCredentialsFile}"
CAPI_DIR="${pythonCapiDir}"
KEY_GROUP="${cfg.python.apiKeyFileGroup}"
# Create directory if it doesn't exist
mkdir -p "$CAPI_DIR"
# Generate unique machine ID based on hostname and a random component
MACHINE_ID="python-$(hostname)-$(openssl rand -hex 4)"
# Generate a secure password
MACHINE_PASSWORD=$(openssl rand -base64 32)
# Check if credentials already exist
if [ -f "$CAPI_FILE" ]; then
echo "CAPI credentials already exist at $CAPI_FILE"
else
# Create credentials file for python-capi-sdk
cat > "$CAPI_FILE" << EOF
# CrowdSec Central API Credentials for python-capi-sdk
# Generated by NixOS infrastructure.crowdsec module
#
# Usage with python-capi-sdk:
# from cscapi.client import CAPIClient, CAPIClientConfig
# from cscapi.sql_storage import SQLStorage
# from cscapi.utils import generate_machine_id_from_key
# import yaml
#
# with open('${cfg.python.capiCredentialsFile}') as f:
# creds = yaml.safe_load(f)
#
# client = CAPIClient(
# storage=SQLStorage(connection_string="sqlite:///signals.db"),
# config=CAPIClientConfig(
# scenarios=${builtins.toJSON cfg.python.capiScenarios}
# )
# )
#
# # Note: Machine enrollment with CrowdSec CAPI requires manual approval
# # Contact CrowdSec for signal sharing partnership details
machine_id: "$MACHINE_ID"
password: "$MACHINE_PASSWORD"
scenarios: ${builtins.toJSON cfg.python.capiScenarios}
capi_url: "https://api.crowdsec.net/"
# Local API connection (for reading decisions)
lapi_url: "http://${cfg.api.listenAddr}:${toString cfg.api.listenPort}/"
EOF
chmod 640 "$CAPI_FILE"
chown root:"$KEY_GROUP" "$CAPI_FILE"
echo "CAPI credentials generated at $CAPI_FILE"
echo ""
echo "NOTE: To share signals with CrowdSec CAPI, you need to:"
echo "1. Contact CrowdSec for signal sharing partnership enrollment"
echo "2. Use the machine_id from this file when enrolling"
echo "3. Update your application to use the python-capi-sdk"
fi
'';
in {
# Python bouncer registration service (oneshot - registers bouncer and stores API key)
systemd.services.crowdsec-python-bouncer = {
description = "CrowdSec Python Bouncer Registration";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pythonBouncerRegisterScript}";
};
};
# Python CAPI credentials service (oneshot - generates CAPI credentials for signal sharing)
systemd.services.crowdsec-python-capi = lib.mkIf cfg.python.enableCapi {
description = "CrowdSec Python CAPI Credentials Generation";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "crowdsec.service" "crowdsec-python-bouncer.service" ];
requires = [ "crowdsec.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pythonCapiRegisterScript}";
};
};
}
);
}
@@ -0,0 +1,730 @@
# CrowdSec - Collaborative Intrusion Prevention System
#
# This module provides simplified boolean feature toggles for common use cases
# and can use either a custom implementation or the native NixOS module.
#
# Module structure:
# - default.nix: Core CrowdSec engine and detection features
# - bouncers/: Response modules (firewall, haproxy, python)
# - integrations/: External system integrations (auditd, console)
{ config, pkgs, lib, options, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
# ==========================================================================
# Version Detection (must not depend on cfg to avoid recursion)
# ==========================================================================
# Check if the native services.crowdsec module exists (NixOS 25.11+)
hasNativeCrowdsecModule = options ? services && options.services ? crowdsec;
# The native module in NixOS 25.11 has multiple bugs that make it unusable:
# - #445342: Missing sensible defaults, API server disabled by default
# - #446764: Console enrollment broken
# - #459224: Cannot enable local API
# - Missing hub.postoverflows, hub.scenarios, hub.parsers options
# - Null coercion errors in systemd service generation
#
# We mark the native module as unstable until these are fixed.
# Users can override with implementation = "native" to test.
nativeModuleIsStable = false;
# State directory for CrowdSec
stateDir = "/var/lib/crowdsec";
# Helper to generate YAML format
yamlFormat = pkgs.formats.yaml {};
in
{
# ==========================================================================
# Import Sub-Modules
# ==========================================================================
imports = [
# Bouncers - Response mechanisms
./bouncers/firewall.nix
./bouncers/haproxy.nix
./bouncers/python.nix
# Integrations - External system connections
./integrations/auditd.nix
./integrations/console.nix
];
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName} = {
enable = lib.mkEnableOption ''
CrowdSec - Collaborative Intrusion Prevention System.
CrowdSec is an open-source security automation tool that detects and blocks
malicious behavior by analyzing logs and sharing threat intelligence with
the community.
This module provides simplified boolean feature toggles for common use cases
and can use either a custom implementation or the native NixOS module.
[NIS2 COMPLIANCE]
Article 21(2)(b) - Incident Handling: CrowdSec provides automated threat
detection and response capabilities, helping organizations meet requirements
for detecting, analyzing, and responding to cybersecurity incidents.
Article 21(2)(d) - Network Security: Acts as an Intrusion Detection/Prevention
System (IDS/IPS), a core requirement for protecting network infrastructure.
'';
implementation = lib.mkOption {
type = lib.types.enum [ "auto" "native" "custom" ];
description = ''
Which implementation to use for CrowdSec.
- "auto": Automatically select based on NixOS version and module stability.
Currently defaults to "custom" because the native module has bugs.
- "native": Force use of NixOS's native services.crowdsec module.
Requires NixOS 25.11+. May have bugs - use for testing only.
- "custom": Use the custom implementation that manages its own systemd
service. Works on all NixOS versions with the crowdsec package.
The native module in NixOS 25.11 has several known issues:
- #445342: Missing sensible defaults
- #446764: Console enrollment broken
- #459224: Cannot enable local API
When these are fixed, "auto" will switch to using the native module.
'';
default = "auto";
example = "custom";
};
package = lib.mkOption {
type = lib.types.package;
description = "CrowdSec package to use.";
default = pkgs.crowdsec;
defaultText = lib.literalExpression "pkgs.crowdsec";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "trace" "debug" "info" "warning" "error" "fatal" ];
description = ''
Log level for CrowdSec.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Appropriate logging level
enables proper security event monitoring and incident investigation.
'';
default = "info";
example = "debug";
};
# ==========================================================================
# API Configuration
# ==========================================================================
api = {
listenAddr = lib.mkOption {
type = lib.types.str;
description = ''
Address for the CrowdSec Local API (LAPI) to listen on.
Use "127.0.0.1" for local-only access or "0.0.0.0" for network access.
'';
default = "127.0.0.1";
example = "0.0.0.0";
};
listenPort = lib.mkOption {
type = lib.types.port;
description = "Port for the CrowdSec Local API (LAPI) to listen on.";
default = 8080;
example = 8080;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to open the firewall port for the CrowdSec API.
Only needed if bouncers from other machines need to connect.
'';
default = false;
};
};
# ==========================================================================
# Detection Features (Simple Boolean Options)
# ==========================================================================
features = {
sshProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable SSH brute-force detection and prevention.
Monitors SSH authentication logs to detect and block IP addresses
attempting password guessing or credential stuffing attacks.
[NIS2 COMPLIANCE]
Article 21(2)(i) - Human Resources Security: Protects authentication
systems and helps prevent unauthorized access attempts.
Article 21(2)(j) - Access Control: Provides automated protection
against credential-based attacks on administrative interfaces.
'';
default = true;
};
nginxProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable nginx/web server attack detection.
Monitors nginx access and error logs to detect web-based attacks
including SQL injection, XSS, path traversal, and more.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Provides web application
firewall (WAF) capabilities to protect public-facing services.
Article 21(2)(e) - Supply Chain Security: Helps protect web
services that may be part of the digital supply chain.
'';
default = false;
};
nginxLogPaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Paths to nginx log files to monitor.";
default = [ "/var/log/nginx/*.log" ];
example = [ "/var/log/nginx/access.log" "/var/log/nginx/error.log" ];
};
systemProtection = lib.mkOption {
type = lib.types.bool;
description = ''
Enable system/kernel-level threat detection.
Monitors kernel and system logs for suspicious activity including
privilege escalation attempts and system abuse.
[NIS2 COMPLIANCE]
Article 21(2)(a) - Risk Analysis: Provides continuous monitoring
to identify and respond to system-level threats.
Article 21(2)(g) - Security Monitoring: Implements comprehensive
security monitoring across the system infrastructure.
'';
default = false;
};
communityBlocklists = lib.mkOption {
type = lib.types.bool;
description = ''
Enable community-contributed IP blocklists.
When enrolled in the CrowdSec Console, your instance can receive
curated blocklists of known malicious IPs from the community.
[NIS2 COMPLIANCE]
Article 21(2)(d) - Network Security: Leverages collective threat
intelligence to proactively block known attackers.
Article 14 - Information Sharing: Participates in cybersecurity
information sharing to improve collective defense.
'';
default = true;
};
};
# ==========================================================================
# Hub Configuration (Parsers, Scenarios, Collections)
# ==========================================================================
hub = {
collections = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub collections to install.
Collections bundle related parsers and scenarios together.
Browse available collections at: https://hub.crowdsec.net/
'';
default = [];
example = [ "crowdsecurity/apache2" "crowdsecurity/postfix" ];
};
scenarios = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub scenarios to install.
Scenarios define detection rules for specific attack patterns.
'';
default = [];
example = [ "crowdsecurity/http-bf-wordpress_bf" ];
};
parsers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional CrowdSec Hub parsers to install.
Parsers extract structured data from log files.
'';
default = [];
example = [ "crowdsecurity/docker-logs" ];
};
postoverflows = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional post-overflow parsers to install.";
default = [];
example = [ "crowdsecurity/cdn-whitelist" ];
};
};
# ==========================================================================
# Custom Acquisitions
# ==========================================================================
acquisitions = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
description = ''
Additional log sources for CrowdSec to monitor.
Each acquisition defines a log source (file, journalctl, etc.)
and the parser type to use.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Enables comprehensive
log collection and monitoring across all systems.
'';
default = [];
example = lib.literalExpression ''
[
{
source = "journalctl";
journalctl_filter = [ "_SYSTEMD_UNIT=postgresql.service" ];
labels.type = "syslog";
}
{
filenames = [ "/var/log/myapp/*.log" ];
labels.type = "syslog";
}
]
'';
};
# ==========================================================================
# Pass-through Configuration
# ==========================================================================
extraSettings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra settings merged into the CrowdSec configuration.
For native implementation: merged into services.crowdsec.settings.
For custom implementation: merged into the generated config.yaml.
'';
default = {};
};
extraLocalConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra settings merged into the local configuration.
For native implementation: merged into services.crowdsec.localConfig.
For custom implementation: not currently used.
'';
default = {};
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf cfg.enable (
let
# ========================================================================
# All cfg-dependent values MUST be defined inside this let block
# to avoid infinite recursion during module evaluation
# ========================================================================
# Determine which implementation to use
useNativeImplementation =
if cfg.implementation == "native" then true
else if cfg.implementation == "custom" then false
else if cfg.implementation == "auto" then
hasNativeCrowdsecModule && nativeModuleIsStable
else false;
# Build acquisitions list based on enabled features
acquisitions = lib.flatten [
# SSH acquisition (journalctl-based)
(lib.optional cfg.features.sshProtection {
source = "journalctl";
journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ];
labels.type = "syslog";
})
# Nginx acquisition (log file-based)
(lib.optional cfg.features.nginxProtection {
filenames = cfg.features.nginxLogPaths;
labels.type = "nginx";
})
# System/kernel logs acquisition
(lib.optional cfg.features.systemProtection {
source = "journalctl";
journalctl_filter = [ "_TRANSPORT=kernel" ];
labels.type = "syslog";
})
# Custom acquisitions from user
cfg.acquisitions
];
# Build hub collections list based on enabled features
hubCollections = lib.flatten [
(lib.optional cfg.features.sshProtection "crowdsecurity/sshd")
(lib.optional cfg.features.nginxProtection "crowdsecurity/nginx")
(lib.optional cfg.features.systemProtection "crowdsecurity/linux")
cfg.hub.collections
];
# ========================================================================
# Custom Implementation: Configuration Files
# ========================================================================
# Generate acquisitions file as multi-document YAML
# CrowdSec expects each acquisition as a separate YAML document (separated by ---)
# We use yamlFormat.generate for each acquisition and concatenate them
acquisitionsFile = pkgs.writeText "acquisitions.yaml" (
lib.concatMapStringsSep "\n---\n" (acq:
builtins.readFile (yamlFormat.generate "acq.yaml" acq)
) acquisitions
);
# Generate simulation file (CrowdSec requires this)
simulationFile = yamlFormat.generate "simulation.yaml" {
simulation = false;
exclusions = [];
};
# Generate main config file (compatible with CrowdSec 1.7.x)
configFile = yamlFormat.generate "config.yaml" {
common = {
daemonize = false;
log_media = "stdout";
log_level = cfg.logLevel;
};
config_paths = {
config_dir = "${stateDir}/config";
data_dir = "${stateDir}/data";
hub_dir = "${stateDir}/hub";
simulation_path = "${stateDir}/config/simulation.yaml";
};
crowdsec_service = {
acquisition_path = "${stateDir}/config/acquisitions.yaml";
parser_routines = 1;
};
cscli = {
output = "human";
};
api = {
client = {
insecure_skip_verify = false;
credentials_path = "${stateDir}/config/local_api_credentials.yaml";
};
server = {
enable = true;
listen_uri = "${cfg.api.listenAddr}:${toString cfg.api.listenPort}";
profiles_path = "${stateDir}/config/profiles.yaml";
online_client = {
credentials_path = "${stateDir}/config/online_api_credentials.yaml";
};
};
};
db_config = {
type = "sqlite";
db_path = "${stateDir}/data/crowdsec.db";
use_wal = true;
};
};
# Generate profiles file (CrowdSec expects multi-document YAML format)
# Use bouncer.banDuration if available, otherwise default to 4h
banDuration = cfg.bouncer.banDuration or "4h";
profilesFile = pkgs.writeText "profiles.yaml" ''
name: default_ip_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
- type: ban
duration: ${banDuration}
on_success: break
'';
# Initialization script - sets up CrowdSec on first run
initScript = pkgs.writeShellScript "crowdsec-init" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep pkgs.nettools pkgs.findutils ]}:$PATH"
STATE_DIR="${stateDir}"
CONFIG_DIR="$STATE_DIR/config"
DATA_DIR="$STATE_DIR/data"
HUB_DIR="$STATE_DIR/hub"
PACKAGE="${cfg.package}"
# Create directories
mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$HUB_DIR"
# Copy configuration files
cp -f ${configFile} "$CONFIG_DIR/config.yaml"
cp -f ${profilesFile} "$CONFIG_DIR/profiles.yaml"
cp -f ${acquisitionsFile} "$CONFIG_DIR/acquisitions.yaml"
cp -f ${simulationFile} "$CONFIG_DIR/simulation.yaml"
# Debug: Show acquisitions file content
echo "Generated acquisitions.yaml:"
cat "$CONFIG_DIR/acquisitions.yaml"
echo ""
# Copy patterns directory from package (required for parser grok patterns)
echo "Looking for patterns directory..."
# Try common locations
PATTERNS_FOUND=0
for PATTERNS_PATH in \
"$PACKAGE/share/crowdsec/config/patterns" \
"$PACKAGE/share/crowdsec/patterns" \
"$PACKAGE/etc/crowdsec/patterns" \
; do
if [ -d "$PATTERNS_PATH" ]; then
echo "Found patterns at: $PATTERNS_PATH"
rm -rf "$CONFIG_DIR/patterns"
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
PATTERNS_FOUND=1
break
fi
done
# If not found in common locations, search the entire package
if [ "$PATTERNS_FOUND" = "0" ]; then
echo "Searching for patterns directory in package..."
PATTERNS_PATH=$(find "$PACKAGE" -type d -name "patterns" 2>/dev/null | head -1)
if [ -n "$PATTERNS_PATH" ]; then
echo "Found patterns at: $PATTERNS_PATH"
rm -rf "$CONFIG_DIR/patterns"
cp -r "$PATTERNS_PATH" "$CONFIG_DIR/patterns"
PATTERNS_FOUND=1
fi
fi
if [ "$PATTERNS_FOUND" = "0" ]; then
echo "WARNING: Could not find patterns directory!"
echo "Package contents:"
ls -la "$PACKAGE/" || true
ls -la "$PACKAGE/share/" || true
ls -la "$PACKAGE/share/crowdsec/" 2>/dev/null || true
fi
# Initialize database if it doesn't exist
if [ ! -f "$DATA_DIR/crowdsec.db" ]; then
echo "Initializing CrowdSec database..."
touch "$CONFIG_DIR/local_api_credentials.yaml"
touch "$CONFIG_DIR/online_api_credentials.yaml"
chmod 640 "$CONFIG_DIR/local_api_credentials.yaml"
chmod 640 "$CONFIG_DIR/online_api_credentials.yaml"
fi
# Generate machine ID if it doesn't exist
if [ ! -f "$CONFIG_DIR/local_api_credentials.yaml" ] || [ ! -s "$CONFIG_DIR/local_api_credentials.yaml" ]; then
echo "Registering local machine..."
cscli -c "$CONFIG_DIR/config.yaml" machines add "$(hostname)" --auto --force || true
fi
# Update hub index
echo "Updating hub index..."
cscli -c "$CONFIG_DIR/config.yaml" hub update || true
# Set correct ownership
chown -R crowdsec:crowdsec "$STATE_DIR"
'';
# Hub installation script (runs after service is started)
hubInstallScript = pkgs.writeShellScript "crowdsec-hub-install" ''
set -e
export PATH="${lib.makeBinPath [ cfg.package pkgs.coreutils pkgs.gnugrep ]}:$PATH"
CONFIG_DIR="${stateDir}/config"
# Wait for API to be ready
for i in $(seq 1 30); do
if cscli -c "$CONFIG_DIR/config.yaml" hub list >/dev/null 2>&1; then
break
fi
sleep 1
done
# Install collections
${lib.concatMapStringsSep "\n" (c: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" collections list 2>/dev/null | grep -q "${c}"; then
cscli -c "$CONFIG_DIR/config.yaml" collections install ${c} || true
fi
'') hubCollections}
# Install additional scenarios
${lib.concatMapStringsSep "\n" (s: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" scenarios list 2>/dev/null | grep -q "${s}"; then
cscli -c "$CONFIG_DIR/config.yaml" scenarios install ${s} || true
fi
'') cfg.hub.scenarios}
# Install additional parsers
${lib.concatMapStringsSep "\n" (p: ''
if ! cscli -c "$CONFIG_DIR/config.yaml" parsers list 2>/dev/null | grep -q "${p}"; then
cscli -c "$CONFIG_DIR/config.yaml" parsers install ${p} || true
fi
'') cfg.hub.parsers}
'';
in lib.mkMerge [
# ==========================================================================
# Common Configuration (both implementations)
# ==========================================================================
{
# Assertions
assertions = [
{
assertion = acquisitions != [];
message = ''
CrowdSec requires at least one acquisition source.
Enable at least one of:
- infrastructure.crowdsec.features.sshProtection = true
- infrastructure.crowdsec.features.nginxProtection = true
- infrastructure.crowdsec.features.systemProtection = true
Or add custom acquisitions via infrastructure.crowdsec.acquisitions
'';
}
{
assertion = cfg.implementation != "native" || hasNativeCrowdsecModule;
message = ''
CrowdSec native implementation requires NixOS 25.11 or later.
Either:
1. Upgrade to NixOS 25.11+
2. Set infrastructure.crowdsec.implementation = "custom"
3. Set infrastructure.crowdsec.implementation = "auto" (recommended)
'';
}
];
# Open firewall for LAPI if configured
networking.firewall.allowedTCPPorts =
lib.mkIf cfg.api.openFirewall [ cfg.api.listenPort ];
# Install useful CLI tools
environment.systemPackages = [
cfg.package # Includes cscli
];
}
# ==========================================================================
# Custom Implementation
# ==========================================================================
(lib.mkIf (!useNativeImplementation) {
# Create crowdsec user and group
users.users.crowdsec = {
isSystemUser = true;
group = "crowdsec";
home = stateDir;
description = "CrowdSec daemon user";
};
users.groups.crowdsec = {};
# Ensure data directories exist and create config symlink for cscli
systemd.tmpfiles.rules = [
"d ${stateDir} 0755 crowdsec crowdsec - -"
"d ${stateDir}/config 0755 crowdsec crowdsec - -"
"d ${stateDir}/data 0755 crowdsec crowdsec - -"
"d ${stateDir}/hub 0755 crowdsec crowdsec - -"
# Create /etc/crowdsec directory and symlink for cscli default config path
"L+ /etc/crowdsec/config.yaml - - - - ${stateDir}/config/config.yaml"
];
# Main CrowdSec service
systemd.services.crowdsec = {
description = "CrowdSec Security Engine";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "local-fs.target" ];
serviceConfig = {
Type = "simple";
User = "crowdsec";
Group = "crowdsec";
ExecStartPre = [
"+${initScript}" # Run as root for permissions
];
ExecStart = "${cfg.package}/bin/crowdsec -c ${stateDir}/config/config.yaml";
ExecStartPost = "${hubInstallScript}";
Restart = "always";
RestartSec = "10s";
# Security hardening
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [ stateDir ];
# Allow journal access for systemd log sources
SupplementaryGroups = lib.optional (cfg.features.sshProtection || cfg.features.systemProtection) "systemd-journal";
};
};
})
# ==========================================================================
# Native Implementation (NixOS 25.11+)
# ==========================================================================
(lib.mkIf (useNativeImplementation && hasNativeCrowdsecModule) {
# Workarounds for native module bugs
systemd.tmpfiles.rules = [
# WORKAROUND #445342: Create state directory
# WORKAROUND #446764: Create online_api_credentials.yaml
"f /var/lib/crowdsec/online_api_credentials.yaml 0640 crowdsec crowdsec - -"
];
services.crowdsec = {
enable = true;
package = cfg.package;
# Hub items to install (only collections - other options may not exist)
hub = {
collections = hubCollections;
};
# Local configuration (acquisitions)
localConfig = {
inherit acquisitions;
} // cfg.extraLocalConfig;
# Main settings
settings = lib.mkMerge [
{
# WORKAROUND: BUG #445342 - Enable API server by default
general.api.server.enable = true;
}
# Console enrollment (if configured)
# Note: console options are defined in integrations/console.nix
(lib.mkIf (cfg.console.enrollKeyFile != null) {
console.tokenFile = cfg.console.enrollKeyFile;
})
# User's extra settings
cfg.extraSettings
];
};
})
]
);
}
@@ -0,0 +1,87 @@
# CrowdSec Auditd Integration Module
# Provides kernel-level security event monitoring via Linux Audit Framework
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.auditd = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable auditd integration with CrowdSec.
When enabled, configures auditd to send audit events to CrowdSec
for analysis. This enables detection of:
- Privilege escalation attempts
- Unauthorized file access
- System call anomalies
- User authentication events
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Provides kernel-level
visibility into security events and potential threats.
'';
default = false;
};
rules = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Additional auditd rules to configure for CrowdSec monitoring.
These rules are added to the system's auditd configuration.
Common rules for security monitoring:
- File integrity: "-w /etc/passwd -p wa -k identity"
- Privilege escalation: "-w /usr/bin/sudo -p x -k privilege"
- Network configuration: "-w /etc/hosts -p wa -k network"
'';
default = [];
example = [
"-w /etc/passwd -p wa -k identity"
"-w /etc/shadow -p wa -k identity"
"-w /etc/sudoers -p wa -k privilege"
];
};
nixWrappersWhitelistProcess = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of process names to whitelist from auditd monitoring.
NOTE: This feature is currently disabled due to compatibility issues
with the 'comm' field filter in some versions of auditd. The option
is preserved for future use when auditd compatibility is resolved.
NixOS uses wrapper scripts in /run/wrappers/bin for setuid/setgid
programs (like sudo, ping, etc.). These wrappers can generate a lot
of noise in auditd logs.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Reduces audit log noise
while maintaining security visibility on critical processes.
'';
default = [];
example = [ "sshd" "systemd" "sudo" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
config = lib.mkIf (cfg.enable && cfg.auditd.enable) {
# Enable the Linux Audit daemon
security.auditd.enable = true;
# Add user-defined audit rules
# Note: The nixWrappersWhitelistProcess feature is currently disabled
# due to auditd compatibility issues with the 'comm' field filter
security.audit.rules = cfg.auditd.rules;
};
}
@@ -0,0 +1,97 @@
# CrowdSec Console Integration Module
# Provides cloud enrollment and community threat intelligence sharing
{ config, pkgs, lib, ... }:
let
appName = "crowdsec";
cfg = config.infrastructure.${appName};
in
{
# ==========================================================================
# Options
# ==========================================================================
options.infrastructure.${appName}.console = {
enrollKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Path to file containing the CrowdSec Console enrollment key.
Enrolling connects your instance to the CrowdSec Console for:
- Centralized monitoring and management
- Access to community and commercial blocklists
- Threat intelligence dashboards
- Alert visualization and analytics
Get your enrollment key from: https://app.crowdsec.net/
The enrollment key should be stored securely, for example using
agenix or sops-nix for secrets management.
[NIS2 COMPLIANCE]
Article 21(2)(g) - Security Monitoring: Provides centralized
visibility into security events across infrastructure.
Article 23 - Reporting: Facilitates incident documentation
and reporting through centralized logging.
'';
default = null;
example = "/run/secrets/crowdsec-enroll-key";
};
shareDecisions = lib.mkOption {
type = lib.types.bool;
description = ''
Share your detected threats with the CrowdSec community.
When enabled, anonymized attack signals are shared to improve
collective threat intelligence for all CrowdSec users. This is
a key part of CrowdSec's collaborative security model.
Shared data includes:
- Source IP addresses of attacks
- Attack type/scenario that triggered
- Timestamp of the attack
Personal data and log contents are NOT shared.
[NIS2 COMPLIANCE]
Article 14 - Information Sharing: Contributes to EU-wide
cybersecurity by participating in threat intelligence sharing.
'';
default = true;
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Custom name for this instance in the CrowdSec Console.
If not set, the hostname will be used. Useful for identifying
machines in multi-server deployments.
'';
default = null;
example = "web-server-01";
};
tags = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Tags to apply to this instance in the CrowdSec Console.
Tags help organize and filter machines in the console dashboard.
'';
default = [];
example = [ "production" "web-tier" "eu-west" ];
};
};
# ==========================================================================
# Configuration
# ==========================================================================
# Note: The actual console enrollment is handled by the main module
# since it requires integration with both native and custom implementations.
# This module only defines the options.
#
# For native implementation: settings are passed to services.crowdsec.settings
# For custom implementation: enrollment is done via cscli in the init script
}
@@ -0,0 +1,423 @@
{ config, pkgs, lib, ... }:
let
appName = "n8n";
defaultPort = 5678;
cfg = config.infrastructure.${appName};
# Build the custom n8n package with version selection
n8nPackage = if cfg.package != null then cfg.package else
pkgs.callPackage ./package.nix {
version = cfg.version;
buildMemoryMB = cfg.buildMemoryMB;
};
# Environment variables for n8n configuration
n8nEnvironment = {
# Network settings
N8N_PORT = toString cfg.bindToPort;
N8N_LISTEN_ADDRESS = cfg.bindToIp;
# Execution settings
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
# Webhook URL (if specified)
WEBHOOK_URL = cfg.webhookUrl;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
# Database settings (only set if using PostgreSQL)
DB_TYPE = "postgresdb";
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
DB_POSTGRESDB_SSL_ENABLED = "true";
}) // cfg.settings;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.n8n";
# ==========================================================================
# Package and Version Configuration
# ==========================================================================
version = lib.mkOption {
type = lib.types.str;
description = ''
n8n version to install.
Supported versions are defined in package.nix. To add a new version,
you need to compute the source and pnpm dependency hashes.
See package.nix for instructions on adding new versions.
'';
default = "2.1.5";
example = "1.120.4";
};
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
description = ''
Custom n8n package to use. If null, the package will be built
using the version specified in 'version' option.
Use this to provide a completely custom n8n build.
'';
default = null;
example = lib.literalExpression "pkgs.n8n";
};
buildMemoryMB = lib.mkOption {
type = lib.types.int;
description = ''
Maximum Node.js heap size in MB for building n8n.
Increase this if you encounter "JavaScript heap out of memory" errors during build.
'';
default = 4096;
example = 8192;
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind n8n to.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for n8n web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for n8n.";
default = false;
};
# ==========================================================================
# Webhook Configuration
# ==========================================================================
webhookUrl = lib.mkOption {
type = lib.types.str;
description = ''
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
This is the external URL where webhooks can reach n8n.
'';
default = "";
example = "https://n8n.example.com/";
};
# ==========================================================================
# Data Directory
# ==========================================================================
dataDir = lib.mkOption {
type = lib.types.path;
description = "Directory where n8n data is stored.";
default = "/var/lib/n8n";
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "postgresdb" ];
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
default = "sqlite";
};
postgresdb = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host.";
default = "localhost";
example = "/run/postgresql";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
database = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "n8n";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "n8n";
};
passwordSecretName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the secret containing the PostgreSQL password.
The secret should be placed at /run/secrets/<n>.
If null, peer/socket authentication is assumed.
'';
default = null;
example = "n8n-db-password";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL for PostgreSQL connection.";
default = false;
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database user locally.
This requires PostgreSQL to be running locally with trust or peer authentication.
The database itself should be created via infrastructure.postgresql.initialDatabases.
'';
default = false;
};
};
};
# ==========================================================================
# Execution Configuration
# ==========================================================================
executions = {
pruneData = lib.mkOption {
type = lib.types.bool;
description = "Enable automatic pruning of old execution data.";
default = true;
};
pruneDataMaxAge = lib.mkOption {
type = lib.types.int;
description = "Maximum age of execution data in hours before pruning.";
default = 336; # 14 days
};
pruneDataMaxCount = lib.mkOption {
type = lib.types.int;
description = "Maximum number of executions to keep.";
default = 10000;
};
};
# ==========================================================================
# n8n Settings (pass-through as environment variables)
# ==========================================================================
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional n8n configuration as environment variables.
These are passed directly to the n8n service.
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
'';
default = {};
example = lib.literalExpression ''
{
GENERIC_TIMEZONE = "Europe/London";
WORKFLOWS_DEFAULT_NAME = "My Workflow";
N8N_METRICS = "true";
}
'';
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for n8n.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "n8n.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Disable the native n8n service (we'll configure our own systemd service)
# ==========================================================================
# Do NOT enable services.n8n - we create our own service to have full control
# ==========================================================================
# n8n User and Group
# ==========================================================================
users.users.n8n = {
isSystemUser = true;
group = "n8n";
home = cfg.dataDir;
createHome = true;
description = "n8n service user";
};
users.groups.n8n = {};
# ==========================================================================
# n8n Systemd Service
# ==========================================================================
systemd.services.n8n = {
description = "n8n - Workflow Automation";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ] ++
lib.optionals cfg.reverseProxy.enable [ "nginx.service" ] ++
lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"postgresql.service"
"n8n-db-setup.service"
];
wants = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"n8n-db-setup.service"
];
requires = lib.optionals (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) [
"postgresql.service"
];
environment = n8nEnvironment;
serviceConfig = {
Type = "simple";
User = "n8n";
Group = "n8n";
WorkingDirectory = cfg.dataDir;
ExecStart = "${n8nPackage}/bin/n8n";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
locations."/" = {
proxyPass = "http://${cfg.bindToIp}:${toString cfg.bindToPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
chunked_transfer_encoding off;
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Service Dependencies
# ==========================================================================
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
wants = [ "n8n.service" ];
};
# ==========================================================================
# PostgreSQL Database Setup (Optional)
# ==========================================================================
systemd.services.n8n-db-setup = lib.mkIf (cfg.database.type == "postgresdb" && cfg.database.postgresdb.createLocally) {
description = "Create n8n database user";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" ];
requires = [ "postgresql.service" ];
before = [ "n8n.service" ];
requiredBy = [ "n8n.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
};
script = let
dbUser = cfg.database.postgresdb.user;
dbName = cfg.database.postgresdb.database;
dbHost = cfg.database.postgresdb.host;
dbPort = toString cfg.database.postgresdb.port;
in ''
# Wait for PostgreSQL to be ready
until ${pkgs.postgresql}/bin/pg_isready -h ${dbHost} -p ${dbPort}; do
sleep 1
done
# Create database user if it doesn't exist
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "SELECT 1 FROM pg_roles WHERE rolname='${dbUser}'" | grep -q 1 || \
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "CREATE USER ${dbUser}"
# Grant privileges on database
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -c "GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser}"
${pkgs.postgresql}/bin/psql -h ${dbHost} -p ${dbPort} -d ${dbName} -c "GRANT ALL ON SCHEMA public TO ${dbUser}"
'';
};
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
jq
];
};
}
@@ -0,0 +1,176 @@
# Custom n8n package with version selection
# Based on: https://github.com/NixOS/nixpkgs/blob/nixos-25.11/pkgs/by-name/n8/n8n/package.nix
#
# To add a new version:
# 1. Get the source hash:
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
# 2. Get the pnpm deps hash by running a build with lib.fakeHash and copying the correct hash from error
# 3. Add entry to versionHashes below
{
lib,
stdenv,
fetchFromGitHub,
nodejs,
pnpm_10,
fetchPnpmDeps,
pnpmConfigHook,
python3,
node-gyp,
cctools,
xcbuild,
libkrb5,
libmongocrypt,
libpq,
makeWrapper,
# Custom parameters
version ? "2.1.5",
buildMemoryMB ? 4096,
}:
let
# Known version hashes
# To add a new version, run:
# nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@VERSION.tar.gz
# Then build with lib.fakeHash for pnpmDepsHash to get the correct hash
versionHashes = {
"2.1.5" = {
srcHash = "sha256-/MPY3j/2I3CgX5rRhzj3v7bHjaQEDMNnkVfk3taCrYA=";
pnpmDepsHash = "sha256-FRoZIINONy0kFPQAJhOwnCUv7HHwdgqm3r5SJmq4UYk=";
};
"2.1.4" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
"2.0.0" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
"1.120.4" = {
srcHash = "sha256-gUqQM/eA7GnvFYiduSGkj/MCvgWNQPhDLExAJz67bHg=";
pnpmDepsHash = "sha256-UWiN3NvI8We16KwY5JspyX0ok1PJWVg0T5zw+0SnrWk=";
};
"1.91.3" = {
srcHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pnpmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
};
# Get hashes for the requested version
hashes = versionHashes.${version} or (throw ''
n8n version ${version} is not supported.
Supported versions: ${builtins.concatStringsSep ", " (builtins.attrNames versionHashes)}
To add support for version ${version}:
1. Get source hash: nix-prefetch-url --unpack https://github.com/n8n-io/n8n/archive/refs/tags/n8n@${version}.tar.gz
2. Add entry to versionHashes in app_modules/_unstable/n8n/package.nix
3. Build once with placeholder pnpmDepsHash to get the correct hash from the error message
'');
in
stdenv.mkDerivation (finalAttrs: {
pname = "n8n";
inherit version;
src = fetchFromGitHub {
owner = "n8n-io";
repo = "n8n";
tag = "n8n@${finalAttrs.version}";
hash = hashes.srcHash;
};
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pnpm_10;
fetcherVersion = 2;
hash = hashes.pnpmDepsHash;
};
nativeBuildInputs = [
pnpmConfigHook
pnpm_10
python3 # required to build sqlite3 bindings
node-gyp # required to build sqlite3 bindings
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
cctools
xcbuild
];
buildInputs = [
nodejs
libkrb5
libmongocrypt
libpq
];
# Set memory limit for Node.js during build
env = {
NODE_OPTIONS = "--max-old-space-size=${toString buildMemoryMB}";
};
buildPhase = ''
runHook preBuild
pushd node_modules/sqlite3
node-gyp rebuild
popd
# TODO: use deploy after resolved https://github.com/pnpm/pnpm/issues/5315
pnpm build --filter=n8n
runHook postBuild
'';
preInstall = ''
echo "Removing non-deterministic and unnecessary files"
find -type d -name .turbo -exec rm -rf {} +
rm node_modules/.modules.yaml
rm -f packages/nodes-base/dist/types/nodes.json
CI=true pnpm --ignore-scripts prune --prod
find -type f \( -name "*.ts" -o -name "*.map" \) -exec rm -rf {} +
rm -rf node_modules/.pnpm/{typescript*,prettier*}
shopt -s globstar
# https://github.com/pnpm/pnpm/issues/3645
find node_modules packages/**/node_modules -xtype l -delete
echo "Removed non-deterministic and unnecessary files"
'';
installPhase = ''
runHook preInstall
mkdir -p $out/{bin,lib/n8n}
mv {packages,node_modules} $out/lib/n8n
makeWrapper $out/lib/n8n/packages/cli/bin/n8n $out/bin/n8n \
--set N8N_RELEASE_TYPE "stable"
runHook postInstall
'';
# this package has ~80000 files, these take too long and seem to be unnecessary
dontStrip = true;
dontPatchELF = true;
dontRewriteSymlinks = true;
meta = {
description = "Free and source-available fair-code licensed workflow automation tool";
longDescription = ''
Free and source-available fair-code licensed workflow automation tool.
Easily automate tasks across different services.
'';
homepage = "https://n8n.io";
changelog = "https://github.com/n8n-io/n8n/releases/tag/n8n@${finalAttrs.version}";
maintainers = with lib.maintainers; [
gepbird
AdrienLemaire
];
license = lib.licenses.sustainableUse;
mainProgram = "n8n";
platforms = lib.platforms.unix;
};
})
@@ -0,0 +1,8 @@
# Wrapper module that imports the unstable crowdsec module
# This allows the standard app_modules/default.nix import to work
{ ... }:
{
imports = [
../_unstable/crowdsec
];
}
+18
View File
@@ -0,0 +1,18 @@
{
imports = [
./elasticsearch
./haproxy
./home-assistant
./mariadb
./minio
./mongodb
./mongodb-pod
./n8n-pod
./nextcloud
./nginx
./opensearch
./postgresql
./rabbitmq
./redis
];
}
@@ -0,0 +1,100 @@
{ config, pkgs, lib, ... }:
let
appName = "elasticsearch";
defaultHttpPort = 9200;
defaultTransportPort = 9300;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.elasticsearch";
package = lib.mkOption {
type = lib.types.package;
description = "Elasticsearch package to use.";
default = pkgs.elasticsearch;
example = "pkgs.elasticsearch7";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind for HTTP API.";
default = "127.0.0.1";
};
httpPort = lib.mkOption {
type = lib.types.int;
description = "Port for HTTP API.";
default = defaultHttpPort;
};
transportPort = lib.mkOption {
type = lib.types.int;
description = "Port for transport/cluster communication.";
default = defaultTransportPort;
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Data directory for Elasticsearch.";
default = "/var/lib/elasticsearch";
};
clusterName = lib.mkOption {
type = lib.types.str;
description = "Name of the Elasticsearch cluster.";
default = "elasticsearch";
};
singleNode = lib.mkOption {
type = lib.types.bool;
description = "Run as a single-node cluster (disables bootstrap checks).";
default = true;
};
heapSize = lib.mkOption {
type = lib.types.str;
description = "JVM heap size for Elasticsearch (e.g., '512m', '1g').";
default = "512m";
};
extraSettings = lib.mkOption {
type = lib.types.attrs;
description = "Extra settings to add to elasticsearch.yml.";
default = {};
example = { "action.destructive_requires_name" = true; };
};
};
config = lib.mkIf cfg.enable {
services.elasticsearch = {
enable = true;
package = cfg.package;
dataDir = cfg.dataDir;
cluster_name = cfg.clusterName;
listenAddress = cfg.bindToIp;
port = cfg.httpPort;
tcp_port = cfg.transportPort;
single_node = cfg.singleNode;
extraConf = lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name}: ${builtins.toJSON value}") cfg.extraSettings
);
extraJavaOptions = [
"-Xms${cfg.heapSize}"
"-Xmx${cfg.heapSize}"
];
};
# Install curl for API access
environment.systemPackages = [ pkgs.curl pkgs.jq ];
# Open firewall for Elasticsearch if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.httpPort
cfg.transportPort
];
};
}
@@ -0,0 +1,718 @@
{ config, pkgs, lib, ... }:
let
appName = "haproxy";
cfg = config.infrastructure.${appName};
# Generate combined PEM file path for a domain
combinedPemPath = domain: "/var/lib/acme/${domain}/combined.pem";
# Self-signed certificate directory
selfSignedCertDir = "/var/lib/haproxy/certs";
# Script to concatenate fullchain.pem and privkey.pem for HAProxy
# HAProxy requires a single file with cert chain + private key
mkCombinePemScript = domain: pkgs.writeShellScript "combine-pem-${domain}" ''
ACME_DIR="/var/lib/acme/${domain}"
COMBINED="$ACME_DIR/combined.pem"
if [ -f "$ACME_DIR/fullchain.pem" ] && [ -f "$ACME_DIR/privkey.pem" ]; then
cat "$ACME_DIR/fullchain.pem" "$ACME_DIR/privkey.pem" > "$COMBINED"
chmod 640 "$COMBINED"
chown acme:haproxy "$COMBINED"
fi
'';
# Script to generate self-signed certificates for testing
mkSelfSignedCertScript = domain: pkgs.writeShellScript "generate-self-signed-${domain}" ''
CERT_DIR="${selfSignedCertDir}"
COMBINED="$CERT_DIR/${domain}.pem"
mkdir -p "$CERT_DIR"
# Only generate if not exists or expired
if [ ! -f "$COMBINED" ] || ! ${pkgs.openssl}/bin/openssl x509 -checkend 86400 -noout -in "$COMBINED" 2>/dev/null; then
echo "Generating self-signed certificate for ${domain}..."
${pkgs.openssl}/bin/openssl req -x509 -newkey rsa:4096 \
-keyout "$CERT_DIR/${domain}.key" \
-out "$CERT_DIR/${domain}.crt" \
-sha256 -days 365 -nodes \
-subj "/CN=${domain}" \
-addext "subjectAltName=DNS:${domain},DNS:*.${domain}"
# Combine for HAProxy
cat "$CERT_DIR/${domain}.crt" "$CERT_DIR/${domain}.key" > "$COMBINED"
chmod 640 "$COMBINED"
chown haproxy:haproxy "$COMBINED"
rm -f "$CERT_DIR/${domain}.key" "$CERT_DIR/${domain}.crt"
fi
'';
# Default HAProxy global configuration
defaultGlobalConfig = ''
global
log /dev/log local0
log /dev/log local1 notice
maxconn 4096
# Modern SSL settings
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11
tune.ssl.default-dh-param 2048
'';
# Default HAProxy defaults configuration
defaultDefaultsConfig = ''
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 50s
timeout server 50s
timeout http-request 10s
timeout http-keep-alive 10s
errorfile 400 /dev/null
errorfile 403 /dev/null
errorfile 408 /dev/null
errorfile 500 /dev/null
errorfile 502 /dev/null
errorfile 503 /dev/null
errorfile 504 /dev/null
'';
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.haproxy";
package = lib.mkOption {
type = lib.types.package;
description = "HAProxy package to use.";
default = pkgs.haproxy;
example = "pkgs.haproxy-lts";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
default = true;
};
user = lib.mkOption {
type = lib.types.str;
description = "User account under which HAProxy runs.";
default = "haproxy";
};
group = lib.mkOption {
type = lib.types.str;
description = "Group account under which HAProxy runs.";
default = "haproxy";
};
# ==========================================================================
# Let's Encrypt / ACME Configuration
# ==========================================================================
acme = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable ACME (Let's Encrypt) certificate management.";
default = false;
};
acceptTerms = lib.mkOption {
type = lib.types.bool;
description = ''
Accept the ACME provider's terms of service.
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
'';
default = false;
};
email = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default email address for ACME certificate registration and renewal notifications.";
default = null;
example = "admin@example.com";
};
staging = lib.mkOption {
type = lib.types.bool;
description = ''
Use Let's Encrypt staging server for testing.
Certificates won't be trusted but you won't hit rate limits.
'';
default = false;
};
domains = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
extraDomainNames = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional domain names (SANs) for this certificate.";
default = [];
example = [ "www.example.com" "api.example.com" ];
};
webroot = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Webroot path for HTTP-01 challenge. If null, standalone mode is used.";
default = "/var/lib/acme/acme-challenge";
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = "Extra configuration options for this certificate.";
default = {};
};
};
});
description = ''
Domains to obtain certificates for. The key is the primary domain name.
Use extraDomainNames for additional SANs (Subject Alternative Names).
'';
default = {};
example = lib.literalExpression ''
{
"example.com" = {
extraDomainNames = [ "www.example.com" ];
};
"api.example.com" = {};
}
'';
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed to security.acme.defaults.
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
'';
default = {};
example = {
renewInterval = "daily";
};
};
};
# ==========================================================================
# Self-Signed Certificate Configuration (for testing)
# ==========================================================================
selfSigned = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Enable self-signed certificate generation for testing.
These certificates are NOT trusted by browsers but useful for development/testing.
'';
default = false;
};
domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of domains to generate self-signed certificates for.";
default = [];
example = [ "localhost" "test.local" ];
};
regenerate = lib.mkOption {
type = lib.types.bool;
description = "Force regeneration of self-signed certificates on each activation.";
default = false;
};
};
# ==========================================================================
# SSL/TLS Configuration
# ==========================================================================
ssl = {
minVersion = lib.mkOption {
type = lib.types.enum [ "TLSv1.2" "TLSv1.3" ];
description = "Minimum TLS version to accept.";
default = "TLSv1.2";
};
ciphers = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Custom cipher suite for TLS 1.2 and below.";
default = null;
example = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256";
};
ciphersuites = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Custom cipher suite for TLS 1.3.";
default = null;
example = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384";
};
hsts = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable HTTP Strict Transport Security (HSTS) header.";
default = false;
};
maxAge = lib.mkOption {
type = lib.types.int;
description = "HSTS max-age in seconds.";
default = 31536000; # 1 year
};
includeSubDomains = lib.mkOption {
type = lib.types.bool;
description = "Include subdomains in HSTS policy.";
default = true;
};
preload = lib.mkOption {
type = lib.types.bool;
description = "Add preload directive to HSTS header.";
default = false;
};
};
};
# ==========================================================================
# HTTP to HTTPS Redirect
# ==========================================================================
httpToHttpsRedirect = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Automatically redirect HTTP requests to HTTPS.
Creates a frontend on port 80 that redirects all traffic to HTTPS.
'';
default = false;
};
code = lib.mkOption {
type = lib.types.enum [ 301 302 307 308 ];
description = "HTTP redirect status code to use.";
default = 301;
};
excludePaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Paths to exclude from redirect (e.g., ACME challenge).";
default = [ "/.well-known/acme-challenge/" ];
};
};
# ==========================================================================
# HAProxy Configuration
# ==========================================================================
globalConfig = lib.mkOption {
type = lib.types.lines;
description = "HAProxy global section configuration.";
default = defaultGlobalConfig;
example = ''
global
log /dev/log local0
maxconn 2048
'';
};
defaultsConfig = lib.mkOption {
type = lib.types.lines;
description = "HAProxy defaults section configuration.";
default = defaultDefaultsConfig;
example = ''
defaults
log global
mode http
timeout connect 5s
timeout client 50s
timeout server 50s
'';
};
frontends = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
bind = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Bind addresses and ports.";
default = [];
example = [ "*:80" "*:443 ssl crt /path/to/cert.pem" ];
};
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Frontend mode.";
default = "http";
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this frontend.";
default = [];
example = [ "httplog" "forwardfor" ];
};
acls = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "ACL definitions.";
default = [];
example = [ "is_api path_beg /api" "is_static path_beg /static" ];
};
httpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-request rules.";
default = [];
example = [ "set-header X-Forwarded-Proto https if { ssl_fc }" ];
};
httpResponse = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-response rules.";
default = [];
example = [ "set-header Strict-Transport-Security max-age=31536000" ];
};
tcpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "tcp-request rules (for TCP mode).";
default = [];
example = [ "inspect-delay 5s" "content accept if { req_ssl_hello_type 1 }" ];
};
useBackend = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "use_backend rules.";
default = [];
example = [ "api_backend if is_api" "static_backend if is_static" ];
};
defaultBackend = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default backend for this frontend.";
default = null;
example = "web_backend";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this frontend.";
default = "";
};
};
});
description = "HAProxy frontend configurations.";
default = {};
example = lib.literalExpression ''
{
http = {
bind = [ "*:80" ];
defaultBackend = "web_backend";
};
https = {
bind = [ "*:443 ssl crt /var/lib/acme/example.com/combined.pem" ];
httpRequest = [ "set-header X-Forwarded-Proto https" ];
defaultBackend = "web_backend";
};
}
'';
};
backends = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Backend mode.";
default = "http";
};
balance = lib.mkOption {
type = lib.types.str;
description = "Load balancing algorithm.";
default = "roundrobin";
example = "leastconn";
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this backend.";
default = [];
example = [ "httpchk GET /health" ];
};
httpRequest = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-request rules for this backend.";
default = [];
};
httpResponse = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "http-response rules for this backend.";
default = [];
};
tcpCheck = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "tcp-check rules for TCP mode health checking.";
default = [];
example = [ "connect" "send PING\\r\\n" "expect string +PONG" ];
};
servers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Backend server definitions.";
default = [];
example = [ "server1 127.0.0.1:8080 check" "server2 127.0.0.1:8081 check" ];
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this backend.";
default = "";
};
};
});
description = "HAProxy backend configurations.";
default = {};
example = lib.literalExpression ''
{
web_backend = {
balance = "roundrobin";
servers = [ "web1 127.0.0.1:8080 check" "web2 127.0.0.1:8081 check" ];
options = [ "httpchk GET /health" ];
};
}
'';
};
listen = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
bind = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Bind addresses and ports.";
default = [];
};
mode = lib.mkOption {
type = lib.types.enum [ "http" "tcp" ];
description = "Listen mode.";
default = "http";
};
balance = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Load balancing algorithm.";
default = null;
};
options = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "HAProxy options for this listen section.";
default = [];
};
servers = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Server definitions.";
default = [];
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra configuration for this listen section.";
default = "";
};
};
});
description = "HAProxy listen sections (combined frontend/backend).";
default = {};
example = lib.literalExpression ''
{
stats = {
bind = [ "*:8404" ];
options = [ "http-use-htx" "httplog" ];
extraConfig = '''
stats enable
stats uri /stats
stats refresh 10s
''';
};
}
'';
};
extraConfig = lib.mkOption {
type = lib.types.lines;
description = "Extra HAProxy configuration appended to the config file.";
default = "";
};
};
config = lib.mkIf cfg.enable {
# Assertions
assertions = [
{
assertion = !(cfg.acme.enable && cfg.selfSigned.enable);
message = "Cannot enable both ACME and self-signed certificates. Choose one.";
}
{
assertion = cfg.acme.enable -> cfg.acme.acceptTerms;
message = "You must accept the ACME terms of service to use Let's Encrypt.";
}
{
assertion = cfg.acme.enable -> cfg.acme.email != null;
message = "You must provide an email address for ACME certificate registration.";
}
];
# ACME configuration for Let's Encrypt
security.acme = lib.mkIf cfg.acme.enable {
acceptTerms = cfg.acme.acceptTerms;
defaults = {
email = cfg.acme.email;
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
webroot = "/var/lib/acme/acme-challenge";
group = "haproxy";
} // cfg.acme.extraConfig;
# Create certificate configurations for each domain
certs = lib.mapAttrs (domain: domainCfg: {
inherit (domainCfg) extraDomainNames;
webroot = domainCfg.webroot;
# Reload HAProxy after certificate renewal
postRun = ''
# Combine fullchain and privkey for HAProxy
${mkCombinePemScript domain}
# Reload HAProxy to pick up new certificates
${pkgs.systemd}/bin/systemctl reload haproxy.service || true
'';
} // domainCfg.extraConfig) cfg.acme.domains;
};
# Ensure haproxy user is in acme group to read certificates
users.users.haproxy = lib.mkIf cfg.acme.enable {
extraGroups = [ "acme" ];
};
# Create directories for ACME and self-signed certificates
systemd.tmpfiles.rules =
lib.optionals cfg.acme.enable [
"d /var/lib/acme/acme-challenge 0755 acme acme -"
"d /var/lib/acme/acme-challenge/.well-known 0755 acme acme -"
"d /var/lib/acme/acme-challenge/.well-known/acme-challenge 0755 acme acme -"
] ++
lib.optionals cfg.selfSigned.enable [
"d ${selfSignedCertDir} 0750 haproxy haproxy -"
];
# Self-signed certificate generation service
systemd.services.haproxy-generate-self-signed = lib.mkIf cfg.selfSigned.enable {
description = "Generate self-signed certificates for HAProxy";
wantedBy = [ "haproxy.service" ];
before = [ "haproxy.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = lib.concatMapStringsSep "\n" (domain:
"${mkSelfSignedCertScript domain}"
) cfg.selfSigned.domains;
};
# HAProxy configuration
services.haproxy = {
enable = true;
package = cfg.package;
user = cfg.user;
group = cfg.group;
config = let
# HSTS header value
hstsHeader = lib.optionalString cfg.ssl.hsts.enable (
"max-age=${toString cfg.ssl.hsts.maxAge}" +
lib.optionalString cfg.ssl.hsts.includeSubDomains "; includeSubDomains" +
lib.optionalString cfg.ssl.hsts.preload "; preload"
);
# HTTP to HTTPS redirect frontend
httpRedirectFrontend = lib.optionalString cfg.httpToHttpsRedirect.enable ''
frontend http-redirect
bind *:80
mode http
${lib.concatMapStringsSep "\n " (path: "acl is_acme path_beg ${path}") cfg.httpToHttpsRedirect.excludePaths}
${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) "use_backend acme_backend if is_acme"}
http-request redirect scheme https code ${toString cfg.httpToHttpsRedirect.code} unless { ssl_fc }${lib.optionalString (cfg.httpToHttpsRedirect.excludePaths != []) " or is_acme"}
'';
# Generate frontend configuration
frontendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: frontend: ''
frontend ${name}
${lib.concatMapStringsSep "\n " (b: "bind ${b}") frontend.bind}
mode ${frontend.mode}
${lib.concatMapStringsSep "\n " (o: "option ${o}") frontend.options}
${lib.concatMapStringsSep "\n " (a: "acl ${a}") frontend.acls}
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") frontend.httpRequest}
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") frontend.httpResponse}
${lib.concatMapStringsSep "\n " (r: "tcp-request ${r}") frontend.tcpRequest}
${lib.optionalString (cfg.ssl.hsts.enable && frontend.mode == "http") "http-response set-header Strict-Transport-Security \"${hstsHeader}\""}
${lib.concatMapStringsSep "\n " (u: "use_backend ${u}") frontend.useBackend}
${lib.optionalString (frontend.defaultBackend != null) "default_backend ${frontend.defaultBackend}"}
${frontend.extraConfig}
'') cfg.frontends);
# Generate backend configuration
backendConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: backend: ''
backend ${name}
mode ${backend.mode}
balance ${backend.balance}
${lib.concatMapStringsSep "\n " (o: "option ${o}") backend.options}
${lib.concatMapStringsSep "\n " (r: "http-request ${r}") backend.httpRequest}
${lib.concatMapStringsSep "\n " (r: "http-response ${r}") backend.httpResponse}
${lib.concatMapStringsSep "\n " (c: "tcp-check ${c}") backend.tcpCheck}
${lib.concatMapStringsSep "\n " (s: "server ${s}") backend.servers}
${backend.extraConfig}
'') cfg.backends);
# Generate listen configuration
listenConfigs = lib.concatStringsSep "\n\n" (lib.mapAttrsToList (name: listenCfg: ''
listen ${name}
${lib.concatMapStringsSep "\n " (b: "bind ${b}") listenCfg.bind}
mode ${listenCfg.mode}
${lib.optionalString (listenCfg.balance != null) "balance ${listenCfg.balance}"}
${lib.concatMapStringsSep "\n " (o: "option ${o}") listenCfg.options}
${lib.concatMapStringsSep "\n " (s: "server ${s}") listenCfg.servers}
${listenCfg.extraConfig}
'') cfg.listen);
in ''
${cfg.globalConfig}
${cfg.defaultsConfig}
${httpRedirectFrontend}
${frontendConfigs}
${backendConfigs}
${listenConfigs}
${cfg.extraConfig}
'';
};
# Ensure HAProxy starts after certificates are ready
systemd.services.haproxy = lib.mkMerge [
(lib.mkIf cfg.acme.enable {
wants = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
after = lib.mapAttrsToList (domain: _: "acme-${domain}.service") cfg.acme.domains;
})
(lib.mkIf cfg.selfSigned.enable {
wants = [ "haproxy-generate-self-signed.service" ];
after = [ "haproxy-generate-self-signed.service" ];
})
{
serviceConfig = {
# Allow HAProxy to reload without restart
ExecReload = "${pkgs.coreutils}/bin/kill -USR2 $MAINPID";
};
}
];
# Open firewall for HTTP/HTTPS
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
# Install useful utilities
environment.systemPackages = [ cfg.package pkgs.curl pkgs.openssl ];
};
}
@@ -0,0 +1,284 @@
{ config, pkgs, lib, ... }:
let
appName = "home-assistant";
defaultPort = 8123;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.home-assistant";
package = lib.mkOption {
type = lib.types.package;
description = "Home Assistant package to use.";
default = pkgs.home-assistant;
example = "pkgs.home-assistant";
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind Home Assistant to.";
default = "0.0.0.0";
example = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for Home Assistant web interface.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for Home Assistant.";
default = true;
};
# ==========================================================================
# Configuration Directory
# ==========================================================================
configDir = lib.mkOption {
type = lib.types.path;
description = "Directory where Home Assistant configuration is stored.";
default = "/var/lib/hass";
};
configWritable = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to make configuration.yaml writable from the web UI.
This allows editing configuration from Home Assistant's interface.
'';
default = false;
};
# ==========================================================================
# Components and Integrations
# ==========================================================================
extraComponents = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
List of Home Assistant components/integrations to include.
Component names can be found at https://www.home-assistant.io/integrations/
'';
default = [
# Components required for initial onboarding
"esphome"
"met"
"radio_browser"
];
example = [
"esphome"
"met"
"radio_browser"
"hue"
"zwave_js"
"mqtt"
"homekit"
];
};
customComponents = lib.mkOption {
type = lib.types.listOf lib.types.package;
description = "List of custom component packages to install.";
default = [];
};
customLovelaceModules = lib.mkOption {
type = lib.types.listOf lib.types.package;
description = "List of custom Lovelace card packages to load.";
default = [];
};
# ==========================================================================
# Home Assistant Configuration (config.yaml as Nix)
# ==========================================================================
config = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
description = ''
Home Assistant configuration.yaml as a Nix attribute set.
Set to null to use an existing configuration.yaml file.
'';
default = {
# Basic setup with default_config integration
default_config = {};
# HTTP configuration
http = {
server_host = cfg.bindToIp;
server_port = cfg.bindToPort;
};
# Homeassistant core settings
homeassistant = {
name = "Home";
unit_system = "metric";
};
};
example = lib.literalExpression ''
{
default_config = {};
homeassistant = {
name = "My Smart Home";
unit_system = "metric";
time_zone = "Europe/London";
latitude = 51.5074;
longitude = -0.1278;
};
automation = "!include automations.yaml";
scene = "!include scenes.yaml";
}
'';
};
# ==========================================================================
# Lovelace Dashboard Configuration
# ==========================================================================
lovelaceConfig = lib.mkOption {
type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);
description = ''
Lovelace dashboard configuration as a Nix attribute set.
Set to null to use UI-managed dashboards.
'';
default = null;
};
lovelaceConfigWritable = lib.mkOption {
type = lib.types.bool;
description = "Whether to make Lovelace configuration writable.";
default = false;
};
# ==========================================================================
# Reverse Proxy Configuration
# ==========================================================================
reverseProxy = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable nginx reverse proxy for Home Assistant.";
default = false;
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for the reverse proxy.";
default = "localhost";
example = "homeassistant.example.com";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL/HTTPS for the reverse proxy.";
default = false;
};
trustedProxies = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of trusted proxy IP addresses.";
default = [ "127.0.0.1" "::1" ];
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Home Assistant Service Configuration
# ==========================================================================
services.home-assistant = {
enable = true;
package = cfg.package;
configDir = cfg.configDir;
configWritable = cfg.configWritable;
# Components and integrations
extraComponents = cfg.extraComponents;
customComponents = cfg.customComponents;
customLovelaceModules = cfg.customLovelaceModules;
# Configuration
config = if cfg.config != null then (cfg.config // {
# Always include HTTP config if using reverse proxy
http = (cfg.config.http or {}) // (lib.mkIf cfg.reverseProxy.enable {
use_x_forwarded_for = true;
trusted_proxies = cfg.reverseProxy.trustedProxies;
});
}) else null;
# Lovelace configuration
lovelaceConfig = cfg.lovelaceConfig;
lovelaceConfigWritable = cfg.lovelaceConfigWritable;
};
# ==========================================================================
# Nginx Reverse Proxy (Optional)
# ==========================================================================
services.nginx = lib.mkIf cfg.reverseProxy.enable {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = cfg.reverseProxy.ssl;
virtualHosts.${cfg.reverseProxy.hostName} = {
forceSSL = cfg.reverseProxy.ssl;
enableACME = cfg.reverseProxy.ssl;
extraConfig = ''
proxy_buffering off;
'';
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.bindToPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optionals cfg.reverseProxy.enable [ 80 443 ])
);
# ==========================================================================
# Service Dependencies
# ==========================================================================
systemd.services.home-assistant = {
after = lib.mkIf cfg.reverseProxy.enable [ "nginx.service" ];
};
systemd.services.nginx = lib.mkIf cfg.reverseProxy.enable {
wants = [ "home-assistant.service" ];
};
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
];
};
}
@@ -0,0 +1,107 @@
{ config, pkgs, lib, ... }:
let
appName = "mariadb";
appPort = 3306;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mariadb";
package = lib.mkOption {
type = lib.types.package;
description = "MariaDB package to use.";
default = pkgs.mariadb;
example = "pkgs.mariadb_110";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
initialDatabases = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Database name.";
};
schema = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "Path to SQL schema file to import.";
default = null;
};
};
});
description = "List of databases to create on initialization.";
default = [];
example = [ { name = "myapp"; } { name = "testdb"; schema = ./schema.sql; } ];
};
ensureUsers = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "User name.";
};
ensurePermissions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Permissions to grant to the user.";
default = {};
example = { "*.*" = "ALL PRIVILEGES"; };
};
};
});
description = ''
List of users to ensure exist. Users are created with Unix socket
authentication by default (no password required for local connections
when the system username matches the MySQL username).
'';
default = [];
example = [ { name = "myuser"; ensurePermissions = { "mydb.*" = "ALL PRIVILEGES"; }; } ];
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = "Additional MariaDB settings.";
default = {};
example = {
max_connections = 200;
innodb_buffer_pool_size = "1G";
};
};
};
config = lib.mkIf cfg.enable {
services.mysql = {
enable = true;
package = cfg.package;
settings = {
mysqld = {
bind-address = cfg.bindToIp;
port = cfg.bindToPort;
} // cfg.settings;
};
# Create initial databases if specified
initialDatabases = cfg.initialDatabases;
# Create users if specified
ensureUsers = cfg.ensureUsers;
};
# Open firewall for MariaDB if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,100 @@
{ config, pkgs, lib, ... }:
let
appName = "minio";
defaultApiPort = 9000;
defaultConsolePort = 9001;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.minio";
package = lib.mkOption {
type = lib.types.package;
description = "MinIO package to use.";
default = pkgs.minio;
example = "pkgs.minio";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
apiPort = lib.mkOption {
type = lib.types.int;
description = "Port for S3 API.";
default = defaultApiPort;
};
consolePort = lib.mkOption {
type = lib.types.int;
description = "Port for web console.";
default = defaultConsolePort;
};
dataDir = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Data directories for MinIO storage.";
default = [ "/var/lib/minio/data" ];
example = [ "/var/lib/minio/data1" "/var/lib/minio/data2" ];
};
configDir = lib.mkOption {
type = lib.types.str;
description = "Configuration directory for MinIO.";
default = "/var/lib/minio/config";
};
rootCredentialsSecretName = lib.mkOption {
type = lib.types.str;
description = ''
Name of the secret containing root credentials.
The secret file should contain:
MINIO_ROOT_USER=<user>
MINIO_ROOT_PASSWORD=<password>
The secret will be loaded from:
/run/secrets/<secretName>
'';
example = "minio-root-credentials";
};
region = lib.mkOption {
type = lib.types.str;
description = "Region for MinIO server.";
default = "us-east-1";
};
browser = lib.mkOption {
type = lib.types.bool;
description = "Enable or disable the web browser console.";
default = true;
};
};
config = lib.mkIf cfg.enable {
services.minio = {
enable = true;
package = cfg.package;
listenAddress = "${cfg.bindToIp}:${toString cfg.apiPort}";
consoleAddress = "${cfg.bindToIp}:${toString cfg.consolePort}";
dataDir = cfg.dataDir;
configDir = cfg.configDir;
rootCredentialsFile = "/run/secrets/${cfg.rootCredentialsSecretName}";
region = cfg.region;
browser = cfg.browser;
};
# Install MinIO client for CLI access
environment.systemPackages = [ pkgs.minio-client pkgs.curl pkgs.jq ];
# Open firewall for MinIO if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.apiPort
cfg.consolePort
];
};
}
@@ -0,0 +1,61 @@
{ config, pkgs, lib, ... }:
let
appName = "mongodb-pod";
appPort = 27017;
cfg = config.infrastructure.${appName};
dataDir = "/var/lib/mongodb-pod";
execStartPreScript = pkgs.writeShellScript "preStart" ''
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
'';
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mongodb-pod oci";
image = lib.mkOption {
type = lib.types.str;
description = "MongoDB Docker image to use.";
default = "mongo:6";
example = "mongo:4.4.29-focal";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
};
config = lib.mkIf cfg.enable {
# https://stackoverflow.com/questions/42912755/how-to-create-a-db-for-mongodb-container-on-start-up
infrastructure.oci-containers.backend = "podman";
infrastructure.oci-containers.containers.${appName} = {
app = {
name = appName;
};
image = cfg.image;
autoStart = true;
ports = [
"${cfg.bindToIp}:${toString cfg.bindToPort}:27017"
];
bindToIp = cfg.bindToIp;
volumes = [
"${dataDir}:/data/db"
];
execHooks = {
ExecStartPre = [
"${execStartPreScript}"
];
};
};
};
}
@@ -0,0 +1,48 @@
{ config, pkgs, lib, ... }:
let
appName = "mongodb";
defaultPort = 27017;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.mongodb";
package = lib.mkOption {
type = lib.types.package;
description = "MongoDB package to use.";
default = pkgs.mongodb-ce;
example = "pkgs.mongodb";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = defaultPort;
};
};
config = lib.mkIf cfg.enable {
services.mongodb = {
enable = true;
package = cfg.package;
bind_ip = cfg.bindToIp;
extraConfig = lib.mkIf (cfg.bindToPort != defaultPort) ''
net.port: ${toString cfg.bindToPort}
'';
};
# Install mongosh for CLI access
environment.systemPackages = [ pkgs.mongosh ];
# Open firewall for MongoDB if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,234 @@
{ config, pkgs, lib, ... }:
let
appName = "n8n-pod";
defaultPort = 5678;
cfg = config.infrastructure.${appName};
dataDir = "/var/lib/n8n-pod";
execStartPreScript = pkgs.writeShellScript "preStart" ''
${pkgs.coreutils}/bin/mkdir -p ${dataDir}
${pkgs.coreutils}/bin/chown -R 1000:1000 ${dataDir}
'';
# Build environment variables for the container
containerEnv = {
# Network settings
N8N_PORT = toString defaultPort;
N8N_LISTEN_ADDRESS = "0.0.0.0"; # Always bind to all interfaces inside container
# Execution settings
EXECUTIONS_DATA_PRUNE = if cfg.executions.pruneData then "true" else "false";
EXECUTIONS_DATA_MAX_AGE = toString cfg.executions.pruneDataMaxAge;
EXECUTIONS_DATA_PRUNE_MAX_COUNT = toString cfg.executions.pruneDataMaxCount;
} // (lib.optionalAttrs (cfg.webhookUrl != "") {
WEBHOOK_URL = cfg.webhookUrl;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb") {
DB_TYPE = "postgresdb";
DB_POSTGRESDB_HOST = cfg.database.postgresdb.host;
DB_POSTGRESDB_PORT = toString cfg.database.postgresdb.port;
DB_POSTGRESDB_DATABASE = cfg.database.postgresdb.database;
DB_POSTGRESDB_USER = cfg.database.postgresdb.user;
}) // (lib.optionalAttrs (cfg.database.type == "postgresdb" && cfg.database.postgresdb.ssl) {
DB_POSTGRESDB_SSL_ENABLED = "true";
}) // cfg.settings;
# Convert environment to list of "KEY=VALUE" strings
envList = lib.mapAttrsToList (name: value: "${name}=${toString value}") containerEnv;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.n8n-pod oci";
image = lib.mkOption {
type = lib.types.str;
description = "n8n Docker image to use.";
default = "docker.n8n.io/n8nio/n8n:latest";
example = "docker.n8n.io/n8nio/n8n:1.70.0";
};
# ==========================================================================
# Network Configuration
# ==========================================================================
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind n8n to on the host.";
default = "127.0.0.1";
example = "0.0.0.0";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port for n8n web interface on the host.";
default = defaultPort;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for n8n.";
default = false;
};
# ==========================================================================
# Webhook Configuration
# ==========================================================================
webhookUrl = lib.mkOption {
type = lib.types.str;
description = ''
WEBHOOK_URL for n8n, used when running behind a reverse proxy.
This is the external URL where webhooks can reach n8n.
'';
default = "";
example = "https://n8n.example.com/";
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "postgresdb" ];
description = "Database type to use. SQLite is default, PostgreSQL recommended for production.";
default = "sqlite";
};
postgresdb = {
host = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL host. Use host IP for container access.";
default = "host.containers.internal";
example = "192.168.1.100";
};
port = lib.mkOption {
type = lib.types.int;
description = "PostgreSQL port.";
default = 5432;
};
database = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL database name.";
default = "n8n";
};
user = lib.mkOption {
type = lib.types.str;
description = "PostgreSQL user.";
default = "n8n";
};
password = lib.mkOption {
type = lib.types.str;
description = ''
PostgreSQL password. For production, consider using
passwordFile or environment variable injection instead.
'';
default = "";
example = "secretpassword";
};
ssl = lib.mkOption {
type = lib.types.bool;
description = "Enable SSL for PostgreSQL connection.";
default = false;
};
};
};
# ==========================================================================
# Execution Configuration
# ==========================================================================
executions = {
pruneData = lib.mkOption {
type = lib.types.bool;
description = "Enable automatic pruning of old execution data.";
default = true;
};
pruneDataMaxAge = lib.mkOption {
type = lib.types.int;
description = "Maximum age of execution data in hours before pruning.";
default = 336; # 14 days
};
pruneDataMaxCount = lib.mkOption {
type = lib.types.int;
description = "Maximum number of executions to keep.";
default = 10000;
};
};
# ==========================================================================
# n8n Settings (pass-through as environment variables)
# ==========================================================================
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional n8n configuration as environment variables.
See https://docs.n8n.io/hosting/environment-variables/environment-variables/
'';
default = {};
example = lib.literalExpression ''
{
GENERIC_TIMEZONE = "Europe/London";
WORKFLOWS_DEFAULT_NAME = "My Workflow";
N8N_METRICS = "true";
}
'';
};
};
config = lib.mkIf cfg.enable {
# Configure podman backend
infrastructure.oci-containers.backend = "podman";
infrastructure.oci-containers.containers.${appName} = {
app = {
name = appName;
};
image = cfg.image;
autoStart = true;
ports = [
"${cfg.bindToIp}:${toString cfg.bindToPort}:${toString defaultPort}"
];
bindToIp = cfg.bindToIp;
# Mount data directory for persistence
volumes = [
"${dataDir}:/home/node/.n8n"
];
# Environment variables
environment = containerEnv;
# Run as node user (UID 1000 in official image)
user = "1000:1000";
execHooks = {
ExecStartPre = [
"${execStartPreScript}"
];
};
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.bindToPort ];
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
jq
];
};
}
@@ -0,0 +1,273 @@
{ config, pkgs, lib, ... }:
let
appName = "nextcloud";
defaultPort = 80;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.nextcloud";
package = lib.mkOption {
type = lib.types.package;
description = "Nextcloud package to use.";
default = pkgs.nextcloud31;
example = "pkgs.nextcloud30";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Hostname for Nextcloud.";
default = "localhost";
example = "cloud.example.com";
};
https = lib.mkOption {
type = lib.types.bool;
description = "Whether to use HTTPS.";
default = false;
};
# ==========================================================================
# Admin Configuration
# ==========================================================================
admin = {
user = lib.mkOption {
type = lib.types.str;
description = "Admin username.";
default = "admin";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Path to file containing admin password.";
};
};
# ==========================================================================
# Database Configuration
# ==========================================================================
database = {
type = lib.mkOption {
type = lib.types.enum [ "sqlite" "pgsql" "mysql" ];
description = "Database type to use.";
default = "pgsql";
};
name = lib.mkOption {
type = lib.types.str;
description = "Database name.";
default = "nextcloud";
};
user = lib.mkOption {
type = lib.types.str;
description = "Database user.";
default = "nextcloud";
};
host = lib.mkOption {
type = lib.types.str;
description = "Database host. Use socket path for local connections.";
default = "/run/postgresql";
example = "127.0.0.1";
};
createLocally = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to create the database and user locally.
Only works for PostgreSQL and MySQL/MariaDB when using socket authentication.
'';
default = true;
};
};
# ==========================================================================
# Caching Configuration
# ==========================================================================
caching = {
redis = lib.mkOption {
type = lib.types.bool;
description = "Enable Redis for caching and file locking.";
default = false;
};
apcu = lib.mkOption {
type = lib.types.bool;
description = "Enable APCu for local caching.";
default = true;
};
memcached = lib.mkOption {
type = lib.types.bool;
description = "Enable Memcached for distributed caching.";
default = false;
};
};
# ==========================================================================
# PHP Configuration
# ==========================================================================
maxUploadSize = lib.mkOption {
type = lib.types.str;
description = "Maximum upload size.";
default = "512M";
example = "1G";
};
phpOptions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Additional PHP options.";
default = {};
example = {
"opcache.interned_strings_buffer" = "16";
"opcache.max_accelerated_files" = "10000";
};
};
# ==========================================================================
# Extra Configuration
# ==========================================================================
extraApps = lib.mkOption {
type = lib.types.attrsOf lib.types.package;
description = "Extra Nextcloud apps to install.";
default = {};
example = lib.literalExpression ''
with config.services.nextcloud.package.packages.apps; {
inherit calendar contacts notes;
}
'';
};
extraAppsEnable = lib.mkOption {
type = lib.types.bool;
description = "Automatically enable extra apps.";
default = true;
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Additional Nextcloud configuration settings.
These are passed directly to services.nextcloud.settings.
'';
default = {};
example = {
default_phone_region = "US";
overwriteprotocol = "https";
};
};
};
config = lib.mkIf cfg.enable {
# ==========================================================================
# Nextcloud Service Configuration
# ==========================================================================
services.nextcloud = {
enable = true;
package = cfg.package;
hostName = cfg.hostName;
https = cfg.https;
# Admin configuration
config = {
adminuser = cfg.admin.user;
adminpassFile = cfg.admin.passwordFile;
# Database configuration
dbtype = cfg.database.type;
dbname = cfg.database.name;
dbuser = cfg.database.user;
dbhost = cfg.database.host;
};
# Database creation
database.createLocally = cfg.database.createLocally;
# Caching configuration
caching = {
redis = cfg.caching.redis;
apcu = cfg.caching.apcu;
memcached = cfg.caching.memcached;
};
# Configure Redis for file locking if enabled
configureRedis = cfg.caching.redis;
# PHP settings
maxUploadSize = cfg.maxUploadSize;
phpOptions = {
"opcache.enable" = "1";
"opcache.enable_cli" = "1";
"opcache.interned_strings_buffer" = "8";
"opcache.max_accelerated_files" = "10000";
"opcache.memory_consumption" = "128";
"opcache.save_comments" = "1";
"opcache.revalidate_freq" = "1";
} // cfg.phpOptions;
# Extra apps
extraApps = cfg.extraApps;
extraAppsEnable = cfg.extraAppsEnable;
# Additional settings
settings = {
default_phone_region = "US";
maintenance_window_start = 1;
} // cfg.settings;
};
# ==========================================================================
# Nginx Configuration (automatically enabled by Nextcloud module)
# ==========================================================================
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
};
# ==========================================================================
# Service Dependencies
# ==========================================================================
# Ensure Nextcloud starts after its dependencies
systemd.services.nextcloud-setup = {
after = lib.mkMerge [
# Database dependencies
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
# Redis dependency
(lib.mkIf cfg.caching.redis [ "redis-nextcloud.service" ])
];
requires = lib.mkMerge [
(lib.mkIf (cfg.database.type == "pgsql" && cfg.database.createLocally) [ "postgresql.service" ])
(lib.mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) [ "mysql.service" ])
];
};
# ==========================================================================
# Firewall Configuration
# ==========================================================================
networking.firewall.allowedTCPPorts = [ 80 443 ];
# ==========================================================================
# Utilities
# ==========================================================================
environment.systemPackages = with pkgs; [
curl
];
};
}
@@ -0,0 +1,186 @@
{ config, pkgs, lib, ... }:
let
appName = "nginx";
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.nginx";
package = lib.mkOption {
type = lib.types.package;
description = "Nginx package to use.";
default = pkgs.nginx;
example = "pkgs.nginxMainline";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Whether to open firewall ports for HTTP (80) and HTTPS (443).";
default = true;
};
recommendedSettings = lib.mkOption {
type = lib.types.bool;
description = ''
Enable recommended nginx settings for optimization and security.
This enables recommendedGzipSettings, recommendedOptimisation,
recommendedProxySettings, and recommendedTlsSettings.
'';
default = true;
};
# ==========================================================================
# Let's Encrypt / ACME Configuration
# ==========================================================================
acme = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable ACME (Let's Encrypt) certificate management.";
default = false;
};
acceptTerms = lib.mkOption {
type = lib.types.bool;
description = ''
Accept the ACME provider's terms of service.
For Let's Encrypt: https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
'';
default = false;
};
email = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Default email address for ACME certificate registration and renewal notifications.";
default = null;
example = "admin@example.com";
};
staging = lib.mkOption {
type = lib.types.bool;
description = ''
Use Let's Encrypt staging server for testing.
Certificates won't be trusted but you won't hit rate limits.
'';
default = false;
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed to security.acme.defaults.
See https://nixos.org/manual/nixos/stable/#module-security-acme for options.
'';
default = {};
example = {
webroot = "/var/lib/acme/acme-challenge";
renewInterval = "daily";
};
};
};
# ==========================================================================
# Pass-through Configuration
# ==========================================================================
virtualHosts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
# Use freeformType to allow any nginx virtualHost options
freeformType = lib.types.attrsOf lib.types.anything;
});
description = ''
Virtual host configurations passed directly to services.nginx.virtualHosts.
See https://nixos.org/manual/nixos/stable/options.html#opt-services.nginx.virtualHosts
Example with Let's Encrypt:
{
"example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
};
};
}
'';
default = {};
example = lib.literalExpression ''
{
"example.com" = {
enableACME = true;
forceSSL = true;
root = "/var/www/example.com";
};
"api.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
proxyWebsockets = true;
};
};
}
'';
};
appendHttpConfig = lib.mkOption {
type = lib.types.lines;
description = "Additional nginx http block configuration.";
default = "";
example = ''
proxy_buffer_size 128k;
proxy_buffers 4 256k;
'';
};
extraConfig = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
description = ''
Extra configuration options passed directly to services.nginx.
Use this for any nginx options not explicitly exposed by this module.
'';
default = {};
example = {
clientMaxBodySize = "100m";
resolver = { addresses = [ "1.1.1.1" ]; };
};
};
};
config = lib.mkIf cfg.enable {
# ACME configuration
security.acme = lib.mkIf cfg.acme.enable {
acceptTerms = cfg.acme.acceptTerms;
defaults = {
email = cfg.acme.email;
server = lib.mkIf cfg.acme.staging "https://acme-staging-v02.api.letsencrypt.org/directory";
} // cfg.acme.extraConfig;
};
# Nginx configuration
services.nginx = {
enable = true;
package = cfg.package;
# Recommended settings
recommendedGzipSettings = cfg.recommendedSettings;
recommendedOptimisation = cfg.recommendedSettings;
recommendedProxySettings = cfg.recommendedSettings;
recommendedTlsSettings = cfg.recommendedSettings;
# Virtual hosts (pass-through)
virtualHosts = cfg.virtualHosts;
# Additional http config
appendHttpConfig = cfg.appendHttpConfig;
} // cfg.extraConfig;
# Open firewall for HTTP/HTTPS
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 443 ];
# Install useful utilities
environment.systemPackages = [ pkgs.curl pkgs.openssl ];
};
}
@@ -0,0 +1,104 @@
{ config, pkgs, lib, ... }:
let
appName = "opensearch";
defaultHttpPort = 9200;
defaultTransportPort = 9300;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.opensearch";
package = lib.mkOption {
type = lib.types.package;
description = "OpenSearch package to use.";
default = pkgs.opensearch;
example = "pkgs.opensearch";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind for HTTP API.";
default = "127.0.0.1";
};
httpPort = lib.mkOption {
type = lib.types.int;
description = "Port for HTTP API.";
default = defaultHttpPort;
};
transportPort = lib.mkOption {
type = lib.types.int;
description = "Port for transport/cluster communication.";
default = defaultTransportPort;
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Data directory for OpenSearch.";
default = "/var/lib/opensearch";
};
clusterName = lib.mkOption {
type = lib.types.str;
description = "Name of the OpenSearch cluster.";
default = "opensearch";
};
singleNode = lib.mkOption {
type = lib.types.bool;
description = "Run as a single-node cluster (disables bootstrap checks).";
default = true;
};
heapSize = lib.mkOption {
type = lib.types.str;
description = "JVM heap size for OpenSearch (e.g., '512m', '1g').";
default = "512m";
};
extraSettings = lib.mkOption {
type = lib.types.attrs;
description = "Extra settings to add to opensearch.yml.";
default = {};
example = { "action.destructive_requires_name" = true; };
};
};
config = lib.mkIf cfg.enable {
services.opensearch = {
enable = true;
package = cfg.package;
dataDir = cfg.dataDir;
settings = lib.mkMerge [
{
"network.host" = cfg.bindToIp;
"http.port" = cfg.httpPort;
"transport.port" = cfg.transportPort;
"cluster.name" = cfg.clusterName;
}
(lib.mkIf cfg.singleNode {
"discovery.type" = "single-node";
})
cfg.extraSettings
];
extraJavaOptions = [
"-Xms${cfg.heapSize}"
"-Xmx${cfg.heapSize}"
];
};
# Install curl for API access
environment.systemPackages = [ pkgs.curl pkgs.jq ];
# Open firewall for OpenSearch if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [
cfg.httpPort
cfg.transportPort
];
};
}
@@ -0,0 +1,70 @@
{ config, pkgs, lib, ... }:
let
appName = "postgresql";
appPort = 5432;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.postgresql";
package = lib.mkOption {
type = lib.types.package;
description = "PostgreSQL package to use.";
default = pkgs.postgresql_16;
example = "pkgs.postgresql_15";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = appPort;
};
initialDatabases = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "List of databases to create on initialization.";
default = [];
example = [ "myapp" "testdb" ];
};
authentication = lib.mkOption {
type = lib.types.lines;
description = "pg_hba.conf authentication rules.";
default = ''
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
};
};
config = lib.mkIf cfg.enable {
services.postgresql = {
enable = true;
package = cfg.package;
enableTCPIP = true;
authentication = cfg.authentication;
settings = {
port = lib.mkDefault cfg.bindToPort;
listen_addresses = lib.mkDefault cfg.bindToIp;
};
# Create initial databases if specified
ensureDatabases = cfg.initialDatabases;
};
# Open firewall for PostgreSQL if binding to non-localhost
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.bindToIp != "127.0.0.1") [ cfg.bindToPort ];
};
}
@@ -0,0 +1,99 @@
{ config, pkgs, lib, ... }:
let
appName = "rabbitmq";
defaultPort = 5672;
defaultManagementPort = 15672;
cfg = config.infrastructure.${appName};
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.rabbitmq";
package = lib.mkOption {
type = lib.types.package;
description = "RabbitMQ package to use.";
default = pkgs.rabbitmq-server;
example = "pkgs.rabbitmq-server";
};
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "AMQP port to bind.";
default = defaultPort;
};
managementPlugin = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Enable the RabbitMQ management plugin (web UI).";
default = true;
};
port = lib.mkOption {
type = lib.types.int;
description = "Port for the management web UI.";
default = defaultManagementPort;
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Additional RabbitMQ plugins to enable.";
default = [];
example = [ "rabbitmq_shovel" "rabbitmq_federation" ];
};
configItems = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Additional RabbitMQ configuration items (key-value pairs).";
default = {};
example = {
"vm_memory_high_watermark" = "0.6";
"disk_free_limit.absolute" = "1GB";
};
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall ports for RabbitMQ.";
default = false;
};
};
config = lib.mkIf cfg.enable {
services.rabbitmq = {
enable = true;
package = cfg.package;
listenAddress = cfg.bindToIp;
port = cfg.bindToPort;
# Enable management plugin if requested
managementPlugin.enable = cfg.managementPlugin.enable;
managementPlugin.port = cfg.managementPlugin.port;
# Combine user plugins with management plugin
plugins = cfg.plugins;
# Pass through additional configuration
configItems = cfg.configItems;
};
# Install rabbitmqadmin CLI tool when management plugin is enabled
environment.systemPackages = lib.mkIf cfg.managementPlugin.enable [
pkgs.rabbitmq-server
];
# Open firewall ports if requested
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall (
[ cfg.bindToPort ] ++
(lib.optional cfg.managementPlugin.enable cfg.managementPlugin.port)
);
};
}
@@ -0,0 +1,134 @@
{ config, pkgs, lib, ... }:
let
appName = "redis";
defaultPort = 6379;
cfg = config.infrastructure.${appName};
# Server options submodule
serverOptions = { name, ... }: {
options = {
enable = lib.mkEnableOption "this Redis server instance" // { default = true; };
bindToIp = lib.mkOption {
type = lib.types.str;
description = "IP address to bind.";
default = "127.0.0.1";
};
bindToPort = lib.mkOption {
type = lib.types.int;
description = "Port to bind.";
default = defaultPort;
};
maxMemory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Maximum memory Redis can use (e.g., '256mb', '1gb'). Null for unlimited.";
default = null;
example = "256mb";
};
maxMemoryPolicy = lib.mkOption {
type = lib.types.enum [ "noeviction" "allkeys-lru" "volatile-lru" "allkeys-random" "volatile-random" "volatile-ttl" ];
description = "Policy for handling keys when maxMemory is reached.";
default = "noeviction";
};
requirePass = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "Password for Redis authentication. Null for no authentication.";
default = null;
};
databases = lib.mkOption {
type = lib.types.int;
description = "Number of databases to configure.";
default = 16;
};
appendOnly = lib.mkOption {
type = lib.types.bool;
description = "Enable append-only file persistence.";
default = false;
};
openFirewall = lib.mkOption {
type = lib.types.bool;
description = "Open firewall for this Redis instance.";
default = false;
};
};
};
# Filter enabled servers
enabledServers = lib.filterAttrs (name: serverCfg: serverCfg.enable) cfg.servers;
in
{
options.infrastructure.${appName} = {
enable = lib.mkEnableOption "infrastructure.redis";
package = lib.mkOption {
type = lib.types.package;
description = "Redis package to use.";
default = pkgs.redis;
example = "pkgs.redis";
};
servers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule serverOptions);
description = ''
Named Redis server instances.
Each server creates a systemd service named redis-<name>.service.
Use an empty string "" for the default server (redis.service).
'';
default = {};
example = lib.literalExpression ''
{
"" = {
bindToPort = 6379;
};
nextcloud = {
bindToPort = 6380;
maxMemory = "256mb";
};
cache = {
bindToPort = 6381;
maxMemory = "512mb";
maxMemoryPolicy = "allkeys-lru";
};
}
'';
};
};
config = lib.mkIf cfg.enable {
# Set the package at the top level
services.redis.package = cfg.package;
# Create each enabled server
services.redis.servers = lib.mapAttrs (name: serverCfg: {
enable = true;
bind = serverCfg.bindToIp;
port = serverCfg.bindToPort;
databases = serverCfg.databases;
appendOnly = serverCfg.appendOnly;
requirePass = serverCfg.requirePass;
settings = lib.mkMerge [
(lib.mkIf (serverCfg.maxMemory != null) {
maxmemory = serverCfg.maxMemory;
maxmemory-policy = serverCfg.maxMemoryPolicy;
})
];
}) enabledServers;
# Install redis-cli for CLI access
environment.systemPackages = [ cfg.package ];
# Open firewall for servers that request it
networking.firewall.allowedTCPPorts = lib.pipe enabledServers [
(lib.filterAttrs (name: serverCfg: serverCfg.openFirewall))
(lib.mapAttrsToList (name: serverCfg: serverCfg.bindToPort))
];
};
}
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env bash
WORK_DIR=${WORK_DIR:-"$(dirname "$0")"}
NIX_INFRA=${NIX_INFRA:-"nix-infra"}
NIXOS_VERSION=${NIXOS_VERSION:-"25.11"}
SSH_KEY=${SSH_KEY:-"nixinfra-machine"}
SSH_EMAIL=${SSH_EMAIL:-"your-email@example.com"}
SECRETS_PWD=${SECRETS_PWD:-"my_secrets_password"}
LOCATION=${LOCATION:-"nbg1"}
MACHINE_TYPE=${MACHINE_TYPE:-"cx23"}
ENV=${ENV:-"$WORK_DIR/.env"}
read -r -d '' __help_text__ <<EOF || true
nix-infra-machine CLI
=====================
Usage: $0 <command> [options]
Commands:
create <nodes> Provision and initialize machines
update <nodes> Update node configuration and deploy apps
upgrade <nodes> Upgrade NixOS version on nodes
rollback <nodes> Rollback to previous NixOS configuration
destroy Destroy machines
--target=<nodes> Nodes to destroy
ssh <node> SSH into a node
cmd Run command on node(s)
--target=<nodes> Target node(s)
<command> Command to execute
action Run app module action
--target=<node> Target node
<module> App module name
<cmd> Action command to run
port-forward Forward port from remote node to local
--target=<node> Target node to forward from
--port-mapping=<l:r> Port mapping as local:remote
claude Launch Claude with nix-infra-machine-mcp
Options:
--env=<file> Environment file (default: .env)
--target=<nodes> Target node(s) for commands
--node-module=<file> Node module file (default: node_types/standalone_machine.nix)
--port-mapping=<l:r> Port mapping as local:remote (for port-forward)
Examples:
# Create a new machine
$0 create --env=.env node001
# Create multiple machines
$0 create --env=.env node001 node002 node003
# SSH into a machine
$0 ssh --env=.env node001
# Run a command on a machine
$0 cmd --env=.env --target=node001 "systemctl status nginx"
# Update configuration
$0 update --env=.env node001
# Destroy machines
$0 destroy --env=.env --target="node001 node002"
# Launch Claude with dev MCP
$0 claude-dev --env=.env
EOF
if [[ "create upgrade rollback destroy update ssh cmd action port-forward claude claude-dev" == *"$1"* ]]; then
CMD="$1"
shift
else
echo "$__help_text__"
exit 1
fi
for i in "$@"; do
case $i in
--help)
echo "$__help_text__"
exit 0
;;
--env=*)
ENV="${i#*=}"
shift
;;
--target=*)
TARGET="${i#*=}"
shift
;;
--port-mapping=*)
PORT_MAPPING="${i#*=}"
shift
;;
--node-module=*)
NODE_MODULE="${i#*=}"
shift
;;
*)
REST="$@"
;;
esac
done
# Read the environment file if provided
if [ "$ENV" != "" ] && [ -f "$ENV" ]; then
source "$ENV"
fi
# Check for nix-infra CLI
if [ "$NIX_INFRA" = "nix-infra" ] && ! command -v nix-infra >/dev/null 2>&1; then
echo "The 'nix-infra' cli is required for this script to work."
echo "Visit https://github.com/jhsware/nix-infra for instructions on installation"
exit 1
fi
# A Hetzner Cloud token is required
if [ -z "$HCLOUD_TOKEN" ]; then
echo "Missing env-var HCLOUD_TOKEN. Load through .env-file that is specified through --env."
exit 1
fi
# ============================================================================
# Validation helpers
# ============================================================================
check_required_vars() {
local missing=""
for var in "$@"; do
if [ -z "${!var}" ]; then
missing="$missing $var"
fi
done
if [ -n "$missing" ]; then
die "Missing required environment variables:$missing
Load through .env-file specified with --env option."
fi
}
check_required_args() {
local name="$1"
local value="$2"
if [ -z "$value" ]; then
die "Missing required argument: $name"
fi
}
# ============================================================================
# Helper Functions
# ============================================================================
printTime() {
local _start=$1; local _end=$2; local _secs=$((_end-_start))
printf '%02dh:%02dm:%02ds' $((_secs/3600)) $((_secs%3600/60)) $((_secs%60))
}
destroyNodes() {
$NIX_INFRA fleet destroy -d "$WORK_DIR" --env="$ENV" --batch \
--target="$TARGET"
}
cleanupOnFail() {
if [ $1 -ne 0 ]; then
echo "$2"
destroyNodes
exit 1
fi
}
# ============================================================================
# Interactive Commands
# ============================================================================
if [ "$CMD" = "ssh" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 ssh --env=.env <node>"
exit 1
fi
$NIX_INFRA fleet ssh -d "$WORK_DIR" --env="$ENV" --target="$REST"
exit 0
fi
if [ "$CMD" = "cmd" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 cmd --env=.env --target=<node> <command>"
exit 1
fi
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "$REST"
exit 0
fi
if [ "$CMD" = "action" ]; then
if [ -z "$TARGET" ] || [ -z "$REST" ]; then
echo "Usage: $0 action --env=.env --target=<node> <module> <cmd>"
exit 1
fi
read -r module action_cmd <<< "$REST"
$NIX_INFRA fleet action -d "$WORK_DIR" --env="$ENV" --target="$TARGET" --app-module="$module" \
--cmd="$action_cmd"
exit 0
fi
if [ "$CMD" = "port-forward" ]; then
if [ -z "$TARGET" ] || [ -z "$PORT_MAPPING" ]; then
echo "Usage: $0 port-forward --env=.env --target=<node> --port-mapping=<local:remote>"
exit 1
fi
OLD_IFS=$IFS
IFS=: read LOCAL_PORT REMOTE_PORT <<< "$PORT_MAPPING"
IFS=$OLD_IFS
$NIX_INFRA fleet port-forward -d "$WORK_DIR" --env="$ENV" \
--target="$TARGET" \
--local-port="$LOCAL_PORT" \
--remote-port="$REMOTE_PORT"
exit 0
fi
# ============================================================================
# Fleet Management Commands
# ============================================================================
if [ "$CMD" = "destroy" ]; then
if [ -z "$TARGET" ]; then
echo "Usage: $0 destroy --env=.env --target=\"<node1> <node2> ...\""
exit 1
fi
destroyNodes
exit 0
fi
if [ "$CMD" = "update" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 update --env=.env <node1> <node2> ..."
exit 1
fi
NODE_MODULE=${NODE_MODULE:-"node_types/standalone_machine.nix"}
$NIX_INFRA fleet update -d "$WORK_DIR" --env="$ENV" --batch \
--nixos-version="$NIXOS_VERSION" \
--target="$REST" \
--node-module="$NODE_MODULE" \
--no-rebuild
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --env="$ENV" --batch \
--target="$REST"
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$REST" "nixos-rebuild switch --fast"
exit 0
fi
if [ "$CMD" = "upgrade" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 upgrade --env=$ENV [--nixos-version='$NIXOS_VERSION'] [node1 node2 ...]"
exit 1
fi
# (cd "$WORK_DIR" && git fetch origin && git reset --hard origin/$(git branch --show-current))
$NIX_INFRA cluster upgrade-nixos -d "$WORK_DIR" --env="$ENV" --batch \
--nixos-version="$NIXOS_VERSION" \
--target="$REST"
exit 0
fi
if [ "$CMD" = "rollback" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 rollback --env=.env <node1> <node2> ..."
exit 1
fi
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$REST" "nixos-rebuild switch --rollback"
exit 0
fi
# ============================================================================
# Create Command - Provision and Initialize Machines
# ============================================================================
if [ "$CMD" = "create" ]; then
if [ -z "$REST" ]; then
echo "Usage: $0 create --env=.env <node1> <node2> ..."
exit 1
fi
TARGET="$REST"
_start=$(date +%s)
# Initialize if not already done (check for ssh directory)
if [ ! -d "$WORK_DIR/ssh" ]; then
echo "Initializing nix-infra..."
$NIX_INFRA init -d "$WORK_DIR" --env="$ENV" --no-cert-auth --batch
fi
# Add SSH key to agent
ssh-add "$WORK_DIR/ssh/$SSH_KEY" 2>/dev/null || true
echo "*** Provisioning NixOS $NIXOS_VERSION ***"
$NIX_INFRA fleet provision -d "$WORK_DIR" --env="$ENV" --batch \
--nixos-version="$NIXOS_VERSION" \
--ssh-key="$SSH_KEY" \
--location="$LOCATION" \
--machine-type="$MACHINE_TYPE" \
--node-names="$TARGET"
cleanupOnFail $? "WARNING: Provisioning failed! Cleaning up..."
_provision=$(date +%s)
echo "*** Initializing machines ***"
NODE_MODULE=${NODE_MODULE:-"node_types/standalone_machine.nix"}
$NIX_INFRA fleet init-machine -d "$WORK_DIR" --env="$ENV" --batch \
--nixos-version="$NIXOS_VERSION" \
--target="$TARGET" \
--node-module="$NODE_MODULE"
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "nixos-rebuild switch --fast"
_init_nodes=$(date +%s)
echo "*** Deploying apps ***"
$NIX_INFRA fleet deploy-apps -d "$WORK_DIR" --env="$ENV" --batch \
--target="$TARGET"
$NIX_INFRA fleet cmd -d "$WORK_DIR" --env="$ENV" --target="$TARGET" "nixos-rebuild switch --fast"
_end=$(date +%s)
echo ""
echo "=========================================="
printf '+ provision %s\n' "$(printTime $_start $_provision)"
printf '+ init nodes %s\n' "$(printTime $_provision $_init_nodes)"
printf '+ deploy apps %s\n' "$(printTime $_init_nodes $_end)"
printf '= TOTAL %s\n' "$(printTime $_start $_end)"
echo "=========================================="
echo "Done!"
exit 0
fi
if [ "$CMD" = "claude" ]; then
if [ -z "$SSH_KEY" ] || [ -z "$HCLOUD_TOKEN" ]; then
echo "You need a .env file in the working directory with SSH_KEY and HCLOUD_TOKEN defined" >&2
exit 1
fi
if ! type nix-infra-machine-mcp &> /dev/null; then
echo "nix-infra-machine-mcp is not installed" >&2
exit 1
fi
# Detect OS and set paths accordingly
case "$(uname -s)" in
Darwin)
path_to_claude="$HOME/Library/Application Support/Claude"
claude_bin="/Applications/Claude.app/Contents/MacOS/Claude"
;;
Linux)
path_to_claude="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
claude_bin="claude"
;;
*)
echo "Unsupported operating system: $(uname -s)" >&2
exit 1
;;
esac
if [ ! -d "$path_to_claude" ]; then
echo "You do not have Claude Application Support files in your user. Start the app once manually and retry." >&2
exit 1
fi
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
fi
# Create mcp configuration for Claude
cat > "$path_to_claude/claude_desktop_config.json" << EOF
{
"mcpServers": {
"nix-infra-machine-mcp": {
"command": "nix-infra-machine-mcp"
}
}
}
EOF
# Run claude
"$claude_bin" 2>&1
# Reset mcp configuration for Claude
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
fi
fi
# ============================================================================
# MCP Support
# ============================================================================
if [ "$CMD" = "claude" ] || [ "$CMD" = "claude-dev" ]; then
check_required_vars SSH_KEY HCLOUD_TOKEN
# Detect OS and set paths accordingly
case "$(uname -s)" in
Darwin)
path_to_claude="$HOME/Library/Application Support/Claude"
claude_bin="/Applications/Claude.app/Contents/MacOS/Claude"
;;
Linux)
path_to_claude="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
claude_bin="claude"
;;
*)
die "Unsupported operating system: $(uname -s)"
;;
esac
if [ ! -d "$path_to_claude" ]; then
die "You do not have Claude Application Support files in your user. Start the app once manually and retry."
fi
# ============================================================================
# Command: claude
# ============================================================================
if [ "$CMD" = "claude" ]; then
if ! command -v nix-infra-cluster-mcp &>/dev/null; then
die "nix-infra-cluster-mcp is not installed"
fi
# Backup existing config
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
fi
# Create mcp configuration for Claude
cat >"$path_to_claude/claude_desktop_config.json" <<EOF
{
"mcpServers": {
"nix-infra-cluster-mcp": {
"command": "nix-infra-cluster-mcp"
}
}
}
EOF
# Run claude
"$claude_bin" 2>&1
# Reset mcp configuration for Claude
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
fi
exit 0
fi
# ============================================================================
# Command: claude-dev
# ============================================================================
if [ "$CMD" = "claude-dev" ]; then
# if ! command -v nix-infra-dev-mcp &>/dev/null; then
# die "nix-infra-dev-mcp is not installed"
# fi
# Backup existing config
if [ -f "$path_to_claude/claude_desktop_config.json" ]; then
cp -f "$path_to_claude/claude_desktop_config.json" "$path_to_claude/claude_desktop_config.json.nix-infra.bak"
fi
# Create mcp configuration for Claude
cat >"$path_to_claude/claude_desktop_config.json" <<EOF
{
"mcpServers": {
"nix-infra-dev-mcp": {
"command": "nix-infra-dev-mcp"
}
}
}
EOF
# Run claude
"$claude_bin" 2>&1
# Reset mcp configuration for Claude
if [ -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" ]; then
cp -f "$path_to_claude/claude_desktop_config.json.nix-infra.bak" "$path_to_claude/claude_desktop_config.json"
fi
exit 0
fi
fi
# If we get here, command wasn't handled
die "Unknown command: $CMD"
+53
View File
@@ -0,0 +1,53 @@
{ lib, pkgs, ... }:
let
sshPort = 22;
sshKey = "[%%sshKey%%]";
nixVersion = "[%%nixVersion%%]"; # 24.05
nodeName = "[%%nodeName%%]"; # node001
clusterNode = lib.fileset.toList (lib.fileset.maybeMissing ./cluster_node.nix);
controlNode = lib.fileset.toList (lib.fileset.maybeMissing ./control_node.nix);
standaloneMachine = lib.fileset.toList (lib.fileset.maybeMissing ./standalone_machine.nix);
nodeConfig = lib.fileset.toList (lib.fileset.maybeMissing ./[%%nodeName%%].nix);
modules = lib.fileset.toList (lib.fileset.maybeMissing ./modules/default.nix);
appModules = lib.fileset.toList (lib.fileset.maybeMissing ./app_modules/default.nix);
in
{
imports = [
./hardware-configuration.nix
./networking.nix # generated at runtime by nixos-infect
] ++ clusterNode ++ controlNode ++ nodeConfig ++ standaloneMachine ++ modules ++ appModules;
boot.tmp.cleanOnBoot = true;
zramSwap.enable = true;
system.stateVersion = nixVersion;
networking.hostName = nodeName;
networking.domain = "";
users.users.root.openssh.authorizedKeys.keys = [ sshKey ];
networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ sshPort ];
networking.firewall.allowedUDPPorts = [ ];
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "yes";
services.openssh.settings.PasswordAuthentication = false;
services.openssh.settings.KbdInteractiveAuthentication = false;
services.openssh.settings.LogLevel = "ERROR";
services.openssh.settings.Macs = [
"hmac-sha2-512-etm@openssh.com"
"hmac-sha2-512" # Required for dartssh
"hmac-sha2-256-etm@openssh.com"
"hmac-sha2-256" # Required for dartssh
"umac-128-etm@openssh.com"
];
services.rsyncd.enable = true;
# Enable Flakes
nix.settings.experimental-features = [ "nix-command" "flakes" ];
environment.systemPackages = with pkgs; [
# Flakes clones its dependencies through the git command
git
];
}
+29
View File
@@ -0,0 +1,29 @@
{
description = "A simple NixOS flake";
inputs = {
# NixOS official package source, using the specified branch here
nixpkgs.url = "github:NixOS/nixpkgs/nixos-[%%nixVersion%%]"; # Can we read this from configuration.nix?
secrix.url = "github:Platonic-Systems/secrix"; # We should probably fork this
};
outputs = { self, nixpkgs, secrix, ... }@inputs: {
# Please replace my-nixos with your hostname
nixosConfigurations.[%%nodeName%%] = nixpkgs.lib.nixosSystem {
system = "[%%hwArch%%]";
modules = [
# Allow commercially licensed packages
{ nixpkgs.config.allowUnfree = true; }
# Import the previous configuration.nix we used,
# so the old configuration file still takes effect
./configuration.nix
{
# Set all inputs parameters as special arguments for all submodules,
# so you can directly use all dependencies in inputs in submodules
_module.args = { inherit inputs; };
}
];
};
apps.x86_64-linux.secrix = inputs.secrix.secrix self;
};
}
+6
View File
@@ -0,0 +1,6 @@
{
imports = [
./oci-containers.nix
./podman.nix
];
}
@@ -0,0 +1,396 @@
{ config, options, lib, pkgs, ... }:
with lib;
let
cfg = config.infrastructure.oci-containers;
proxy_env = config.networking.proxy.envVars;
hostName = config.networking.hostName;
defaultBackend = options.infrastructure.oci-containers.backend.default;
containerOptions =
{ ... }: {
options = {
app = {
name = mkOption {
type = with types; str;
description = "Name of the container.";
example = "hello-world";
};
};
bindToIp = mkOption {
type = with types; nullOr str;
default = null;
description = "IP to bind to.";
example = "127.0.0.1";
};
# Settings for running container
image = mkOption {
type = with types; str;
description = "OCI image to run.";
example = "library/hello-world";
};
imageFile = mkOption {
type = with types; nullOr package;
default = null;
description = ''
Path to an image file to load before running the image. This can
be used to bypass pulling the image from the registry.
The `image` attribute must match the name and
tag of the image contained in this file, as they will be used to
run the container with that image. If they do not match, the
image will be pulled from the registry as usual.
'';
example = literalExpression "pkgs.dockerTools.buildImage {...};";
};
login = {
username = mkOption {
type = with types; nullOr str;
default = null;
description = "Username for login.";
};
passwordFile = mkOption {
type = with types; nullOr str;
default = null;
description = "Path to file containing password.";
example = "/etc/nixos/dockerhub-password.txt";
};
registry = mkOption {
type = with types; nullOr str;
default = null;
description = "Registry where to login to.";
example = "https://docker.pkg.github.com";
};
};
cmd = mkOption {
type = with types; listOf str;
default = [];
description = "Commandline arguments to pass to the image's entrypoint.";
example = literalExpression ''
["--port=9000"]
'';
};
labels = mkOption {
type = with types; attrsOf str;
default = {};
description = "Labels to attach to the container at runtime.";
example = literalExpression ''
{
"traefik.https.routers.example.rule" = "Host(`example.container`)";
}
'';
};
entrypoint = mkOption {
type = with types; nullOr str;
description = "Override the default entrypoint of the image.";
default = null;
example = "/bin/my-app";
};
environment = mkOption {
type = with types; attrsOf str;
default = {};
description = "Environment variables to set for this container.";
example = literalExpression ''
{
DATABASE_HOST = "db.example.org";
DATABASE_PORT = "3306";
}
'';
};
environmentFiles = mkOption {
type = with types; listOf path;
default = [];
description = "Environment files for this container.";
example = literalExpression ''
[
/path/to/.env
/path/to/.env.secret
]
'';
};
environmentSecrets = mkOption {
type = with types; listOf (attrsOf str);
default = [];
description = "Secrets passed to pod as env-vars.";
example = literalExpression ''
# https://systemd.io/CREDENTIALS/
[
{ name = "secret.name"; envVar = "SECRET_NAME"; }
]
'';
};
execHooks = {
ExecStartPre = mkOption {
type = with types; listOf str;
default = [];
description = "Command to run before starting the container.";
};
};
log-driver = mkOption {
type = types.str;
default = "journald";
description = ''
Logging driver for the container. The default of
`"journald"` means that the container's logs will be
handled as part of the systemd unit.
For more details and a full list of logging drivers, refer to respective backends documentation.
For Docker:
[Docker engine documentation](https://docs.docker.com/engine/reference/run/#logging-drivers---log-driver)
For Podman:
Refer to the docker-run(1) man page.
'';
};
ports = mkOption {
type = with types; listOf str;
default = [];
description = ''
Network ports to publish from the container to the outer host.
Valid formats:
- `<ip>:<hostPort>:<containerPort>`
- `<ip>::<containerPort>`
- `<hostPort>:<containerPort>`
- `<containerPort>`
Both `hostPort` and `containerPort` can be specified as a range of
ports. When specifying ranges for both, the number of container
ports in the range must match the number of host ports in the
range. Example: `1234-1236:1234-1236/tcp`
When specifying a range for `hostPort` only, the `containerPort`
must *not* be a range. In this case, the container port is published
somewhere within the specified `hostPort` range.
Example: `1234-1236:1234/tcp`
Refer to the
[Docker engine documentation](https://docs.docker.com/engine/reference/run/#expose-incoming-ports) for full details.
'';
example = literalExpression ''
[
"8080:9000"
]
'';
};
user = mkOption {
type = with types; nullOr str;
default = null;
description = ''
Override the username or UID (and optionally groupname or GID) used
in the container.
'';
example = "nobody:nogroup";
};
volumes = mkOption {
type = with types; listOf str;
default = [];
description = ''
List of volumes to attach to this container.
Note that this is a list of `"src:dst"` strings to
allow for `src` to refer to `/nix/store` paths, which
would be difficult with an attribute set. There are
also a variety of mount options available as a third
field; please refer to the
[docker engine documentation](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) for details.
'';
example = literalExpression ''
[
"volume_name:/path/inside/container"
"/path/on/host:/path/inside/container"
]
'';
};
workdir = mkOption {
type = with types; nullOr str;
default = null;
description = "Override the default working directory for the container.";
example = "/var/lib/hello_world";
};
dependsOn = mkOption {
type = with types; listOf str;
default = [];
description = ''
Define which other containers this one depends on. They will be added to both After and Requires for the unit.
Use the same name as the attribute under `virtualisation.oci-containers.containers`.
'';
example = literalExpression ''
virtualisation.oci-containers.containers = {
node1 = {};
node2 = {
dependsOn = [ "node1" ];
}
}
'';
};
hostname = mkOption {
type = with types; nullOr str;
default = null;
description = "The hostname of the container.";
example = "hello-world";
};
extraOptions = mkOption {
type = with types; listOf str;
default = [];
description = "Extra options for {command}`${defaultBackend} run`.";
example = literalExpression ''
["--network=host"]
'';
};
autoStart = mkOption {
type = types.bool;
default = true;
description = ''
When enabled, the container is automatically started on boot.
If this option is set to false, the container has to be started on-demand via its service.
'';
};
};
};
isValidLogin = login: login.username != null && login.passwordFile != null && login.registry != null;
mkService = name: container: let
dependsOn = map (x: "${cfg.backend}-${x}.service") container.dependsOn;
escapedName = escapeShellArg name;
in {
wantedBy = [] ++ optional (container.autoStart) "multi-user.target";
wants = lib.optional (container.imageFile == null) "network-online.target";
after = lib.optionals (cfg.backend == "docker") [ "docker.service" "docker.socket" ]
# if imageFile is not set, the service needs the network to download the image from the registry
++ lib.optionals (container.imageFile == null) [ "network-online.target" ]
++ dependsOn;
requires = dependsOn;
environment = proxy_env;
path =
if cfg.backend == "docker" then [ config.virtualisation.docker.package ]
else if cfg.backend == "podman" then [ config.virtualisation.podman.package ]
else throw "Unhandled backend: ${cfg.backend}";
script = concatStringsSep " \\\n " ([
"exec ${cfg.backend} run"
"--rm"
"--name=${escapedName}"
"--log-driver=${container.log-driver}"
] ++ optional (container.entrypoint != null)
"--entrypoint=${escapeShellArg container.entrypoint}"
++ optional (container.hostname != null)
"--hostname=${escapeShellArg container.hostname}"
++ lib.optionals (cfg.backend == "podman") [
"--cidfile=/run/podman-${escapedName}.ctr-id"
"--cgroups=no-conmon"
"--sdnotify=conmon"
"-d"
"--replace"
] ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment)
++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles
++ map (s: "-e ${escapeShellArg s.envVar}=$(cat $CREDENTIALS_DIRECTORY/${escapeShellArg s.name})") container.environmentSecrets
++ map (p: "-p ${escapeShellArg p}") container.ports
++ optional (container.user != null) "-u ${escapeShellArg container.user}"
++ map (v: "-v ${escapeShellArg v}") container.volumes
++ (mapAttrsToList (k: v: "-l ${escapeShellArg k}=${escapeShellArg v}") container.labels)
++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}"
++ map escapeShellArg container.extraOptions
++ [container.image]
++ map escapeShellArg container.cmd
);
preStop = if cfg.backend == "podman"
then "podman stop --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
else "${cfg.backend} stop ${name} || true";
postStop = if cfg.backend == "podman"
then "podman rm -f --ignore --cidfile=/run/podman-${escapedName}.ctr-id"
else "${cfg.backend} rm -f ${name} || true";
serviceConfig = {
### There is no generalized way of supporting `reload` for docker
### containers. Some containers may respond well to SIGHUP sent to their
### init process, but it is not guaranteed; some apps have other reload
### mechanisms, some don't have a reload signal at all, and some docker
### images just have broken signal handling. The best compromise in this
### case is probably to leave ExecReload undefined, so `systemctl reload`
### will at least result in an error instead of potentially undefined
### behaviour.
###
### Advanced users can still override this part of the unit to implement
### a custom reload handler, since the result of all this is a normal
### systemd service from the perspective of the NixOS module system.
###
# ExecReload = ...;
###
ExecStartPre = container.execHooks.ExecStartPre;
LoadCredentialEncrypted = map (s: "${s.name}:/root/secrets/${s.name}") container.environmentSecrets;
TimeoutStartSec = 0;
TimeoutStopSec = 120;
Restart = "always";
} // optionalAttrs (cfg.backend == "podman") {
Environment="PODMAN_SYSTEMD_UNIT=podman-${name}.service";
Type="notify";
NotifyAccess="all";
};
};
in {
options.infrastructure.oci-containers = {
backend = mkOption {
type = types.enum [ "podman" "docker" ];
default = if versionAtLeast config.system.stateVersion "22.05" then "podman" else "docker";
description = "The underlying Docker implementation to use.";
};
containers = mkOption {
default = {};
type = types.attrsOf (types.submodule containerOptions);
description = "OCI (Docker) containers to run as systemd services.";
};
};
config = lib.mkIf (cfg.containers != {}) (lib.mkMerge [
{
systemd.services = mapAttrs' (n: v: nameValuePair "${cfg.backend}-${n}" (mkService n v)) cfg.containers;
}
(lib.mkIf (cfg.backend == "podman") {
virtualisation.podman.enable = true;
})
(lib.mkIf (cfg.backend == "docker") {
virtualisation.docker.enable = true;
})
]);
}
+44
View File
@@ -0,0 +1,44 @@
{ config, pkgs, lib, ... }:
let
cfg = config.infrastructure.podman;
in
{
options.infrastructure.podman = {
enable = lib.mkEnableOption "infrastructure.podman";
dockerRegistryHostPort = lib.mkOption {
type = lib.types.str;
description = "Docker Registry IP address";
default = "127.0.0.1:5000";
};
};
config = lib.mkIf cfg.enable {
# Enable common container config files in /etc/containers
virtualisation.containers.enable = true;
virtualisation = {
podman = {
enable = true;
# Create a `docker` alias for podman, to use it as a drop-in replacement
dockerCompat = true;
# Required for containers under podman-compose to be able to talk to each other.
defaultNetwork.settings.dns_enabled = true;
};
};
# Add insecure registry
virtualisation.containers.registries.insecure = [ "${cfg.dockerRegistryHostPort}" ];
# virtualisation.containers.registries."10.10.93.0:5000".insecure = true;
# Useful otherdevelopment tools
environment.systemPackages = with pkgs; [
# dive # look into docker image layers
podman-tui # status of containers in the terminal
# docker-compose # start group of containers for dev
podman-compose # start group of containers for dev
];
};
}
@@ -0,0 +1,16 @@
{ config, pkgs, ... }:
let
# Add variables here
in
{
# This file contains common configuration across your entire fleet
# of standalone machines.
config.environment.systemPackages = with pkgs; [
# Useful tools for debugging and administration
htop
curl
wget
netcat
jq
];
}
+310
View File
@@ -0,0 +1,310 @@
{ config, pkgs, lib, ... }:
let
k8sUpstreamConf = "/run/nginx/k8s-upstream.conf";
k8sHttpUpstreamConf = "/run/nginx/k8s-http-upstream.conf";
updateK8sIp = pkgs.writeShellScriptBin "update-k8s-ip" ''
NEW_IP="$SSH_ORIGINAL_COMMAND"
if ! echo "$NEW_IP" | ${pkgs.gnugrep}/bin/grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Invalid IP: $NEW_IP"
exit 1
fi
CONF="${k8sUpstreamConf}"
CURRENT_IP=$(${pkgs.gnugrep}/bin/grep -oP 'server \K[0-9.]+' "$CONF" 2>/dev/null | head -1)
if [ "$CURRENT_IP" = "$NEW_IP" ]; then
echo "IP unchanged: $NEW_IP"
exit 0
fi
printf 'upstream k8s_tls {\n server %s:443;\n}\n\nupstream k8s_ldap {\n server %s:3389;\n}\n' "$NEW_IP" "$NEW_IP" > "$CONF"
printf 'upstream k8s_http {\n server %s:80;\n}\n' "$NEW_IP" > ${k8sHttpUpstreamConf}
${pkgs.nginx}/bin/nginx -t && ${pkgs.systemd}/bin/systemctl reload nginx
echo "Updated K8s backend IP to $NEW_IP"
'';
in
{
# ──────────────────────────────────────────────
# Firewall (replaces UFW)
# ──────────────────────────────────────────────
networking.firewall.allowedTCPPorts = [
25 # SMTP
465 # SMTP submissions (implicit TLS)
587 # SMTP submission (STARTTLS)
993 # IMAP (implicit TLS)
443 # HTTPS
80 # HTTP (ACME + redirect)
];
# ──────────────────────────────────────────────
# Sysctl hardening (replaces base role)
# ──────────────────────────────────────────────
boot.kernel.sysctl = {
"net.ipv4.conf.all.rp_filter" = 1;
"net.ipv4.conf.default.rp_filter" = 1;
"net.ipv4.conf.all.accept_redirects" = 0;
"net.ipv4.conf.default.accept_redirects" = 0;
"net.ipv4.conf.all.send_redirects" = 0;
"net.ipv4.conf.default.send_redirects" = 0;
"net.ipv4.tcp_syncookies" = 1;
"net.ipv4.icmp_echo_ignore_broadcasts" = 1;
"net.ipv6.conf.all.accept_redirects" = 0;
"net.ipv6.conf.default.accept_redirects" = 0;
};
# ──────────────────────────────────────────────
# Fail2ban
# ──────────────────────────────────────────────
services.fail2ban = {
enable = true;
maxretry = 5;
bantime = "1h";
jails = {
sshd = {
settings = {
enabled = true;
port = "ssh";
maxretry = 3;
bantime = "1h";
findtime = "10m";
};
};
};
};
# ──────────────────────────────────────────────
# ACME / Let's Encrypt (replaces certbot)
# ──────────────────────────────────────────────
security.acme = {
acceptTerms = true;
defaults.email = "admin@rubenhensen.nl";
certs."stalwart.rubenhensen.nl" = {
group = "stalwart-mail";
reloadServices = [ "stalwart-mail" ];
webroot = "/var/lib/acme/acme-challenge";
};
};
# ──────────────────────────────────────────────
# Stalwart mail server
# ──────────────────────────────────────────────
services.stalwart-mail = {
enable = true;
settings = {
server = {
hostname = "stalwart.rubenhensen.nl";
max-connections = 8192;
listener = {
smtp = {
bind = "[::]:25";
protocol = "smtp";
};
submission = {
bind = "[::]:587";
protocol = "smtp";
};
submissions = {
bind = "[::]:465";
protocol = "smtp";
tls.implicit = true;
};
imaptls = {
bind = "[::]:993";
protocol = "imap";
tls.implicit = true;
};
https = {
bind = "127.0.0.1:8443";
protocol = "http";
tls.implicit = true;
};
http = {
bind = "127.0.0.1:8080";
protocol = "http";
};
};
};
certificate.default = {
cert = "%{file:/var/lib/acme/stalwart.rubenhensen.nl/fullchain.pem}%";
private-key = "%{file:/var/lib/acme/stalwart.rubenhensen.nl/key.pem}%";
};
storage = {
data = "rocksdb";
fts = "rocksdb";
blob = "rocksdb";
lookup = "rocksdb";
directory = "ldap";
};
store.rocksdb = {
type = "rocksdb";
path = "/var/lib/stalwart-mail/data";
compression = "lz4";
};
directory.ldap = {
type = "ldap";
url = "ldap://127.0.0.1:3389";
base-dn = "DC=ldap,DC=goauthentik,DC=io";
bind.dn = "cn=ldapservice,ou=users,DC=ldap,DC=goauthentik,DC=io";
bind.secret = "%{file:/run/secrets/stalwart-ldap-password}%";
filter.name = "(&(objectClass=user)(cn=?))";
filter.email = "(&(objectClass=user)(mail=?))";
filter.verify = "(&(objectClass=user)(|(mail=*?*)(cn=*?*)))";
filter.expand = "(&(objectClass=group)(cn=?))";
attribute.name = "cn";
attribute.email = "mail";
attribute.description = "displayName";
};
tracer.stdout = {
type = "stdout";
level = "info";
ansi = false;
enable = true;
};
tracer.log = {
type = "log";
level = "info";
path = "/var/lib/stalwart-mail/logs";
prefix = "stalwart.log";
rotate = "daily";
ansi = false;
enable = true;
};
authentication.fallback-admin = {
user = "admin";
secret = "%{file:/run/secrets/stalwart-admin-password}%";
};
};
};
# Grant stalwart and nginx access to ACME certs
users.users.stalwart-mail.extraGroups = [ "acme" ];
users.users.nginx.extraGroups = [ "stalwart-mail" ];
# ──────────────────────────────────────────────
# Nginx (reverse proxy + stream proxy to K8s)
# ──────────────────────────────────────────────
services.nginx = {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
recommendedProxySettings = true;
eventsConfig = "worker_connections 4096;";
# HTTP upstream for K8s (included from mutable file)
appendHttpConfig = ''
include /run/nginx/k8s-http-upstream.conf;
'';
# Stream config for TLS SNI routing + LDAP proxy
streamConfig = ''
log_format stream '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time "$ssl_preread_server_name"';
access_log /var/log/nginx/stream.log stream;
map $ssl_preread_server_name $tls_backend {
stalwart.rubenhensen.nl local_tls;
default k8s_tls;
}
upstream local_tls {
server 127.0.0.1:8443;
}
include /run/nginx/k8s-upstream.conf;
server {
listen 443;
listen [::]:443;
ssl_preread on;
proxy_pass $tls_backend;
}
# LDAP proxy to K8s Authentik LDAP outpost
server {
listen 127.0.0.1:3389;
proxy_pass k8s_ldap;
}
'';
};
# Create stream.d directory and initial upstream config
systemd.tmpfiles.rules = [
"d /run/secrets 0700 root root -"
"d /var/lib/acme/acme-challenge 0755 acme acme -"
];
# ──────────────────────────────────────────────
# K8s IP update script (called via SSH)
# ──────────────────────────────────────────────
# Allow nginx to read/write mutable upstream configs
systemd.services.nginx.serviceConfig.ReadWritePaths = [ "/run/nginx" ];
systemd.services.nginx.serviceConfig.LimitNOFILE = 65536;
systemd.services.nginx.preStart = lib.mkBefore ''
mkdir -p /run/nginx
test -f /run/nginx/k8s-upstream.conf || printf 'upstream k8s_tls {\n server 127.0.0.1:443;\n}\n\nupstream k8s_ldap {\n server 127.0.0.1:3389;\n}\n' > /run/nginx/k8s-upstream.conf
test -f /run/nginx/k8s-http-upstream.conf || printf 'upstream k8s_http {\n server 127.0.0.1:80;\n}\n' > /run/nginx/k8s-http-upstream.conf
'';
# Stalwart ACME HTTP-01 challenge
services.nginx.virtualHosts."stalwart.rubenhensen.nl" = {
listen = [
{ addr = "0.0.0.0"; port = 80; }
{ addr = "[::]"; port = 80; }
];
locations."/.well-known/acme-challenge/" = {
root = "/var/lib/acme/acme-challenge";
};
locations."/" = {
return = "301 https://$host$request_uri";
};
};
# Catch-all port 80 — proxy to K8s for ACME challenges + redirect
services.nginx.virtualHosts."_" = {
default = true;
listen = [
{ addr = "0.0.0.0"; port = 80; }
{ addr = "[::]"; port = 80; }
];
locations."/" = {
proxyPass = "http://k8s_http";
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;
'';
};
};
# IP update script (as a proper Nix package)
environment.systemPackages = [ updateK8sIp pkgs.openssl ];
# SSH authorized key for K8s IP updater (add the actual pubkey)
users.users.root.openssh.authorizedKeys.keys = [
# nix-infra will set the main SSH key via configuration.nix
# Add the IP updater key with command restriction:
''command="${updateK8sIp}/bin/update-k8s-ip",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII5cMc73rlUCn3mS5FXlu3nO+AUeW2L28jRh22VYIPY4 k8s-ip-updater''
];
# ──────────────────────────────────────────────
# Automatic updates
# ──────────────────────────────────────────────
system.autoUpgrade = {
enable = true;
allowReboot = false;
};
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env sh
BRANCH=${BRANCH:-"main"}
REPO=${REPO:-"git@github.com:jhsware/nix-infra-test-machine.git"}
# Check for nix-infra CLI
if ! command -v git >/dev/null 2>&1; then
echo "You need 'git' for this script to work."
echo "Install git using your prefered package manager. If in doubt, install Determinate Nix"
echo "https://docs.determinate.systems/determinate-nix/ and run: 'nix-shell -p git'"
echo
echo "With nix-shell you get ephemeral shell environments. Learn more:"
echo "https://medium.com/@nonickedgr/exploring-nix-shell-a-game-changer-for-ephemeral-environments-5c622e4074a8"
exit 1
fi
printf "Enter folder name [test-nix-infra-machine]: "
read -r name
name=${name:-test-nix-infra-machine}
if [ -e "./$name" ]; then
echo "Folder or file $name already exists in this directory, aborting."
exit 1
fi
git clone -b "$BRANCH" "$REPO" "$name"
cp "$name/.env.in" "$name/.env"
echo "Done!"
echo
echo "Make sure you have installed nix-infra, then:"
echo
echo "1. cd ./$name"
echo "2. Edit .env"
echo "3. Run:"
echo " ./cli --help - Manage infrastructure (create, destroy, ssh, etc.)"
echo " ./__test__/run-tests.sh --help - Run test suite against machines"
echo
+18
View File
@@ -0,0 +1,18 @@
let
sources = import ./nix/sources.nix;
pkgs = import sources.nixpkgs {};
hcloud = pkgs.callPackage nix/hcloud.nix {};
isMacOS = builtins.match ".*-darwin" pkgs.stdenv.hostPlatform.system != null;
in pkgs.mkShell rec {
name = "nix-infra-machine";
buildInputs = with pkgs; [
hcloud
] ++ (if !isMacOS then [
] else []);
shellHook = ''
'';
}