ops-jrz1/modules/maubot.nix
Dan 8826d62bcc Add maubot integration and infrastructure updates
- maubot.nix: Declarative bot framework with plugin deployment
- backup.nix: Local backup service for Matrix/bridge data
- sna-instagram-bot: Instagram content bridge plugin
- beads: Issue tracking workflow integrated
- spec 004: Browser-based dev environment design
- nixpkgs bump: Oct 22 → Dec 2
- Fix maubot health check (401 = healthy)
2025-12-08 15:55:12 -08:00

393 lines
12 KiB
Nix

# Maubot Matrix bot framework module
# Plugin-based Matrix bot system following established infrastructure patterns
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.maubot;
# Python environment with maubot and Instagram bot dependencies
maubotEnv = pkgs.python3.withPackages (ps: with ps; [
maubot
yt-dlp
# instaloader # Not available in nixpkgs, fallback to yt-dlp only
aiohttp
pillow
]);
in
{
options.services.maubot = {
enable = mkEnableOption "Maubot Matrix bot framework";
homeserverUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:8008";
description = "Matrix homeserver URL for bot connections";
};
serverName = mkOption {
type = types.str;
default = "matrix.talu.uno";
description = "Matrix server name for bot users";
};
port = mkOption {
type = types.port;
default = 29316;
description = "Port for Maubot management interface";
};
adminUser = mkOption {
type = types.str;
default = "admin";
description = "Admin username for Maubot management interface";
};
adminPasswordFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to file containing admin password (more secure than adminPassword option)";
};
secretKeyFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to file containing Maubot secret key for sessions";
};
registrationSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Path to file containing Matrix homeserver registration secret";
};
database = mkOption {
type = types.str;
default = "sqlite:/var/lib/maubot/bot.db";
description = "Database connection string (sqlite:// or postgresql://)";
};
logLevel = mkOption {
type = types.str;
default = "INFO";
description = "Log level (DEBUG, INFO, WARNING, ERROR)";
};
enableEncryption = mkOption {
type = types.bool;
default = true;
description = "Enable end-to-end encryption support for bots";
};
publicUrl = mkOption {
type = types.str;
default = "http://localhost:29316";
description = "Public URL where Maubot management interface is accessible";
};
plugins = mkOption {
type = types.listOf types.path;
default = [];
description = "List of maubot plugin .mbp files to deploy";
};
};
config = mkIf cfg.enable {
# User and group
users.users.maubot = {
isSystemUser = true;
group = "maubot";
home = "/var/lib/maubot";
createHome = true;
};
users.groups.maubot = {};
# Configuration file generation
environment.etc."maubot/config.yaml" = {
text = ''
# Maubot configuration - generated by NixOS
# Database configuration
database: "${cfg.database}"
# Server configuration
server:
hostname: 127.0.0.1
port: ${toString cfg.port}
public_url: ${cfg.publicUrl}
# Admin users for management interface
admins:
${cfg.adminUser}: ${if cfg.adminPasswordFile != null then "REPLACE_ADMIN_PASSWORD" else "changeme-set-password"}
# Bot configuration
api_features:
login: true
plugin: true
plugin_upload: true
instance: true
instance_database: true
log: true
# Logging configuration
logging:
version: 1
formatters:
precise:
format: '[%(levelname)s@%(name)s] %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
file:
class: logging.handlers.RotatingFileHandler
formatter: precise
filename: /var/log/maubot/maubot.log
maxBytes: 52428800
backupCount: 10
loggers:
maubot:
level: ${cfg.logLevel}
mau:
level: ${cfg.logLevel}
aiohttp:
level: WARNING
root:
level: WARNING
handlers: [console, file]
# Plugin directories - using flat keys as expected by maubot
plugin_directories.upload: /var/lib/maubot/plugins
plugin_directories.load:
- /var/lib/maubot/plugins
plugin_directories.trash: /var/lib/maubot/trash
# Plugin databases configuration
plugin_databases:
sqlite: /var/lib/maubot/plugins
postgres: null
postgres_max_conns_per_plugin: 3
postgres_opts: {}
# Crypto configuration
crypto:
allow: ${if cfg.enableEncryption then "true" else "false"}
allow_level: warn
# Secret key for sessions
secret_key: ${if cfg.secretKeyFile != null then "REPLACE_SECRET_KEY" else "insecure-default-change-me"}
'';
user = "maubot";
group = "maubot";
mode = "0440";
};
# Systemd service with hardening
systemd.services.maubot = {
description = "Maubot Matrix bot framework";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "maubot";
Group = "maubot";
WorkingDirectory = "/var/lib/maubot";
# Use StateDirectory for runtime data
# RuntimeDirectory removed to avoid race condition with manual creation
StateDirectory = "maubot";
LogsDirectory = "maubot";
# LoadCredential directives for secure secret injection
LoadCredential =
(optional (cfg.adminPasswordFile != null) "admin-password:${cfg.adminPasswordFile}") ++
(optional (cfg.secretKeyFile != null) "secret-key:${cfg.secretKeyFile}");
# Pre-start script to generate runtime config with secrets
ExecStartPre =
if (cfg.adminPasswordFile != null || cfg.secretKeyFile != null) then
[
(pkgs.writeShellScript "maubot-prepare-config" ''
set -e
# Ensure config directory exists
${pkgs.coreutils}/bin/mkdir -p /var/lib/maubot/config
# Use text substitution to preserve YAML structure while injecting secrets
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 << 'EOF'
import os
import re
# Read base configuration as text
with open('/etc/maubot/config.yaml', 'r') as f:
config_text = f.read()
# Read secrets from CREDENTIALS_DIRECTORY if available
creds_dir = os.environ.get('CREDENTIALS_DIRECTORY')
if creds_dir:
# Replace admin password placeholder
admin_password_file = os.path.join(creds_dir, 'admin-password')
if os.path.exists(admin_password_file):
with open(admin_password_file, 'r') as f:
admin_password = f.read().strip()
config_text = config_text.replace('REPLACE_ADMIN_PASSWORD', admin_password)
# Replace secret key placeholder
secret_key_file = os.path.join(creds_dir, 'secret-key')
if os.path.exists(secret_key_file):
with open(secret_key_file, 'r') as f:
secret_key = f.read().strip()
config_text = config_text.replace('REPLACE_SECRET_KEY', secret_key)
# Write runtime config with restrictive permissions
os.umask(0o077) # Ensure only owner can read
with open('/var/lib/maubot/config/config.yaml', 'w') as f:
f.write(config_text)
EOF
'')
]
else
[
(pkgs.writeShellScript "maubot-prepare-config-simple" ''
${pkgs.coreutils}/bin/mkdir -p /var/lib/maubot/config
${pkgs.coreutils}/bin/cp /etc/maubot/config.yaml /var/lib/maubot/config/config.yaml
'')
];
# Start Maubot with runtime config
ExecStart = "${maubotEnv}/bin/maubot -c /var/lib/maubot/config/config.yaml";
# Restart policy
Restart = "always";
RestartSec = 10;
# Security hardening following established patterns
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
# PrivateTmp disabled to allow access to /run/maubot
PrivateTmp = false;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
# Allow writing to data, log, and runtime directories
ReadWritePaths = [
"/var/lib/maubot"
"/var/log/maubot"
"/run/maubot"
];
# Network restrictions
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
# System calls - Python application needs broader access
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"@network-io"
"@file-system"
"~@privileged"
];
# Resource limits
MemoryMax = "512M";
CPUWeight = 50; # Lower priority than Matrix server
IOWeight = 50;
# Process security
UMask = "0027";
LockPersonality = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
# Logging
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "maubot";
};
};
# Directory permissions
systemd.tmpfiles.rules = [
"d /var/lib/maubot 0755 maubot maubot -"
"d /var/lib/maubot/plugins 0755 maubot maubot -"
"d /var/lib/maubot/trash 0755 maubot maubot -"
"d /var/log/maubot 0755 maubot maubot -"
"d /run/maubot 0700 maubot maubot -"
] ++ (map (plugin:
"L+ /var/lib/maubot/plugins/${baseNameOf plugin} - - - - ${plugin}"
) cfg.plugins);
# Health check service
systemd.services.maubot-health = {
description = "Maubot health check";
after = [ "maubot.service" ];
serviceConfig = {
Type = "oneshot";
User = "nobody";
Group = "nogroup";
ExecStart = pkgs.writeShellScript "maubot-health" ''
# Check if Maubot management interface is responding
# Note: All maubot endpoints require auth, so 401 is expected and healthy
HTTP_CODE=$(${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" "http://localhost:${toString cfg.port}/_matrix/maubot/v1/login" 2>/dev/null)
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Maubot health check: OK (HTTP $HTTP_CODE)"
exit 0
else
echo "Maubot health check: FAILED (HTTP $HTTP_CODE)"
exit 1
fi
'';
StandardOutput = "journal";
StandardError = "journal";
};
};
systemd.timers.maubot-health = {
description = "Maubot health check timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*:0/5"; # Every 5 minutes
Persistent = true;
};
};
# Health check failure handling - restart service if health check fails consistently
systemd.services.maubot-health-restart = {
description = "Restart Maubot on health check failure";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "maubot-health-restart" ''
# Check if maubot health service failed recently
if systemctl is-failed maubot-health.service >/dev/null 2>&1; then
echo "Maubot health check failed, restarting maubot service"
systemctl restart maubot.service
# Reset health check failure state
systemctl reset-failed maubot-health.service
fi
'';
User = "root";
StandardOutput = "journal";
StandardError = "journal";
};
};
systemd.timers.maubot-health-restart = {
description = "Monitor Maubot health check failures and restart if needed";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*:2/10"; # Every 10 minutes, offset from health check
Persistent = true;
};
};
# Maubot management interface only accessible via SSH tunnel (localhost:29316)
# Do NOT expose to internet - admin UI has no rate limiting
};
}