# mautrix-slack Matrix-Slack bridge # Bridges Slack to Matrix via appservice # Implementation follows mautrix-gmessages pattern for config management { config, pkgs, pkgs-unstable, lib, ... }: with lib; let cfg = config.services.mautrix-slack; yamlFormat = pkgs.formats.yaml { }; exampleConfigPath = "${cfg.package}/example-config.yaml"; configOverrides = { homeserver = { address = cfg.matrix.homeserverUrl; domain = cfg.matrix.serverName; }; appservice = { address = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}"; hostname = cfg.appservice.hostname; port = cfg.appservice.port; id = cfg.appservice.id; bot = { username = cfg.appservice.senderLocalpart; displayname = cfg.appservice.botDisplayName; avatar = cfg.appservice.botAvatar; }; username_template = "${cfg.appservice.userPrefix}{{.}}"; }; database = { type = cfg.database.type; uri = cfg.database.uri; max_open_conns = cfg.database.maxOpenConnections; max_idle_conns = cfg.database.maxIdleConnections; }; bridge = { command_prefix = cfg.bridge.commandPrefix; permissions = cfg.bridge.permissions; }; encryption = { allow = cfg.encryption.enable; default = cfg.encryption.default; require = cfg.encryption.require; }; logging = { min_level = cfg.logging.level; }; }; in { options.services.mautrix-slack = { enable = mkEnableOption "mautrix-slack Matrix-Slack bridge"; package = mkOption { type = types.package; default = pkgs-unstable.mautrix-slack; description = "Package providing the bridge executable."; }; user = mkOption { type = types.str; default = "mautrix_slack"; description = "System user for the bridge."; }; group = mkOption { type = types.str; default = "mautrix_slack"; description = "Primary group for the bridge."; }; dataDir = mkOption { type = types.path; default = "/var/lib/mautrix_slack"; description = "State directory."; }; extraPackages = mkOption { type = types.listOf types.package; default = []; description = "Additional packages exposed to the service PATH."; }; extraConfig = mkOption { type = yamlFormat.type; default = {}; description = "Optional YAML fragment merged into the config."; }; # Matrix homeserver configuration matrix = { homeserverUrl = mkOption { type = types.str; default = "http://127.0.0.1:8008"; description = "Matrix homeserver URL for bridge connections"; }; serverName = mkOption { type = types.str; description = "Matrix server name for bridge users"; }; }; # Database configuration database = { type = mkOption { type = types.str; default = "postgres"; description = "Database type (sqlite3 or postgres)"; }; uri = mkOption { type = types.str; default = "postgresql:///mautrix_slack?host=/run/postgresql"; description = "Database connection string"; }; maxOpenConnections = mkOption { type = types.int; default = 32; description = "Maximum number of open database connections"; }; maxIdleConnections = mkOption { type = types.int; default = 4; description = "Maximum number of idle database connections"; }; }; # Logging configuration logging = { level = mkOption { type = types.str; default = "info"; description = "Log level (debug, info, warn, error)"; }; }; # Bridge behavior configuration bridge = { commandPrefix = mkOption { type = types.str; default = "!slack"; description = "Command prefix for bridge commands"; }; permissions = mkOption { type = types.attrsOf types.str; default = { "example.com" = "user"; }; description = "Bridge permissions mapping"; }; }; # Appservice configuration appservice = { id = mkOption { type = types.str; default = "slack"; description = "Appservice ID"; }; hostname = mkOption { type = types.str; default = "127.0.0.1"; description = "Hostname for the appservice"; }; port = mkOption { type = types.port; default = 29319; description = "Port for the appservice API"; }; senderLocalpart = mkOption { type = types.str; default = "slackbot"; description = "Localpart of the bridge bot user"; }; botDisplayName = mkOption { type = types.str; default = "Slack Bridge Bot"; description = "Display name for the bridge bot"; }; botAvatar = mkOption { type = types.str; default = ""; description = "Avatar MXC URI for the bridge bot"; }; userPrefix = mkOption { type = types.str; default = "slack_"; description = "Prefix for Matrix users bridged from Slack"; }; }; # Encryption configuration encryption = { enable = mkOption { type = types.bool; default = true; description = "Enable Matrix end-to-end encryption"; }; default = mkOption { type = types.bool; default = false; description = "Enable encryption by default in new portals"; }; require = mkOption { type = types.bool; default = false; description = "Require encryption in all portals"; }; }; }; config = mkIf cfg.enable { # User and group users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; extraGroups = [ "matrix-appservices" ]; home = cfg.dataDir; createHome = true; }; users.groups.${cfg.group} = {}; users.groups.matrix-appservices = {}; # Systemd service with gmessages-style configuration systemd.services.mautrix-slack = { description = "mautrix-slack Matrix-Slack bridge"; after = [ "network.target" ] ++ optional (cfg.database.type == "postgres") "postgresql.service"; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; SupplementaryGroups = [ "matrix-appservices" ]; WorkingDirectory = cfg.dataDir; # Use StateDirectory for persistent config and data management StateDirectory = cfg.user; StateDirectoryMode = "0750"; # Two-stage pre-start: directory setup as root, then config generation as service user ExecStartPre = [ # Stage 1: Create config directory as root (+ prefix) "+${pkgs.coreutils}/bin/mkdir -p /var/lib/${cfg.user}/config" "+${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} /var/lib/${cfg.user}" "+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}" "+${pkgs.coreutils}/bin/chmod 750 /var/lib/${cfg.user}/config" # Stage 2: Generate configuration using example template "${pkgs.writeShellScript "mautrix-slack-prepare-config" '' set -euo pipefail CONFIG_DIR=/var/lib/${cfg.user}/config CONFIG_PATH="$CONFIG_DIR/config.yaml" REG_PATH="/var/lib/matrix-appservices/mautrix_slack_registration.yaml" # Always regenerate config from example cd "$CONFIG_DIR" rm -f config.yaml ${cfg.package}/bin/mautrix-slack -c config.yaml -e ${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY' import json import os import pathlib import pwd import grp import yaml CONFIG_PATH = pathlib.Path('config.yaml') REG_PATH = pathlib.Path('/var/lib/matrix-appservices/mautrix_slack_registration.yaml') with CONFIG_PATH.open('r', encoding='utf-8') as fh: config = yaml.safe_load(fh) overrides = json.loads(${lib.escapeShellArg (builtins.toJSON configOverrides)}) extra_cfg = json.loads(${lib.escapeShellArg (builtins.toJSON cfg.extraConfig)}) def deep_update(target, data): for key, value in data.items(): if isinstance(value, dict) and value is not None: if key not in target or not isinstance(target.get(key), dict): target[key] = {} deep_update(target[key], value) else: target[key] = value deep_update(config, overrides) if extra_cfg: deep_update(config, extra_cfg) # Handle permissions explicitly to ensure example values are replaced perms = overrides.get('bridge', {}).get('permissions') if perms is not None: config.setdefault('bridge', {})['permissions'] = perms # Read tokens from registration file if it exists if REG_PATH.exists(): with REG_PATH.open('r', encoding='utf-8') as fh: registration = yaml.safe_load(fh) if 'as_token' in registration: config.setdefault('appservice', {})['as_token'] = registration['as_token'] if 'hs_token' in registration: config.setdefault('appservice', {})['hs_token'] = registration['hs_token'] with CONFIG_PATH.open('w', encoding='utf-8') as fh: yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False) uid = pwd.getpwnam('${cfg.user}').pw_uid gid_service = grp.getgrnam('${cfg.group}').gr_gid gid_shared = grp.getgrnam('matrix-appservices').gr_gid os.chown(CONFIG_PATH, uid, gid_service) os.chmod(CONFIG_PATH, 0o640) PY # Generate registration file if it doesn't exist if [ ! -f "$REG_PATH" ]; then mkdir -p $(dirname "$REG_PATH") ${cfg.package}/bin/mautrix-slack -c config.yaml -g -r "$REG_PATH" # Fix sender_localpart to match config (bridge generates random value) # See: https://github.com/mautrix/slack/issues - registration -g ignores config.yaml ${pkgs.gnused}/bin/sed -i "s/^sender_localpart: .*/sender_localpart: ${cfg.appservice.senderLocalpart}/" "$REG_PATH" chown ${cfg.user}:matrix-appservices "$REG_PATH" chmod 640 "$REG_PATH" fi ''}" ]; # Start mautrix-slack with state directory config ExecStart = "${cfg.package}/bin/mautrix-slack -c /var/lib/${cfg.user}/config/config.yaml"; # Restart policy Restart = "always"; RestartSec = "10s"; # Security hardening NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; # Allow writing to data and shared appservices directory ReadWritePaths = [ cfg.dataDir "/var/lib/matrix-appservices" ]; # Resource limits MemoryMax = "256M"; # Process security UMask = "0027"; LockPersonality = true; RestrictRealtime = true; RestrictSUIDSGID = true; # Logging StandardOutput = "journal"; StandardError = "journal"; SyslogIdentifier = "mautrix-slack"; }; # Add extra packages to PATH if specified path = [ pkgs.coreutils ] ++ cfg.extraPackages; }; # Directory permissions systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" "d /var/lib/matrix-appservices 0770 root matrix-appservices -" ]; }; }