# 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 }; }