- 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)
393 lines
12 KiB
Nix
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
|
|
};
|
|
} |