# mautrix-gmessages Matrix-Google Messages bridge # Bridges Google Messages (RCS/SMS/MMS) to Matrix via web interface { config, pkgs, pkgs-unstable, lib, ... }: with lib; let cfg = config.services.matrix-vm.mautrix-gmessages; yamlFormat = pkgs.formats.yaml { }; defaultPermissions = { "*" = "relay"; "${cfg.matrix.serverName}" = "user"; }; effectivePermissions = if cfg.bridge.permissions != {} then cfg.bridge.permissions else defaultPermissions; exampleConfigPath = "${cfg.package}/share/mautrix-gmessages/example-config.yaml"; configOverrides = { network = { displayname_template = cfg.network.displaynameTemplate; device_meta = { os = cfg.network.deviceMeta.os; browser = cfg.network.deviceMeta.browser; type = cfg.network.deviceMeta.type; }; aggressive_reconnect = cfg.network.aggressiveReconnect; initial_chat_sync_count = cfg.network.initialChatSyncCount; }; bridge = { command_prefix = cfg.bridge.commandPrefix; relay = { enabled = cfg.bridge.relay.enabled; admin_only = cfg.bridge.relay.adminOnly; }; caption_in_message = cfg.bridge.captionInMessage; portal_message_buffer = cfg.bridge.portalMessageBuffer; permissions = effectivePermissions; }; database = { type = cfg.database.type; uri = cfg.database.uri; max_open_conns = cfg.database.maxOpenConnections; max_idle_conns = cfg.database.maxIdleConnections; }; 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}{{.}}"; public_address = cfg.appservice.publicAddress; }; double_puppet = { servers = cfg.bridge.additionalDoublePuppetServers; allow_discovery = cfg.bridge.allowDiscovery; secrets = {}; }; encryption = { allow = cfg.encryption.enable; default = cfg.encryption.default; require = cfg.encryption.require; require_verification = cfg.encryption.requireVerification; allow_key_sharing = cfg.encryption.allowKeySharing; }; }; registrationBase = { id = cfg.appservice.id; url = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}"; as_token = "__AS_TOKEN__"; hs_token = "__HS_TOKEN__"; sender_localpart = cfg.appservice.senderLocalpart; rate_limited = false; namespaces = { users = [ { regex = "^@${cfg.appservice.senderLocalpart}:${cfg.matrix.serverName}$"; exclusive = true; } { regex = "^@${cfg.appservice.userPrefix}.*:${cfg.matrix.serverName}$"; exclusive = true; } ]; }; "de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral; receive_ephemeral = true; }; registrationFile = yamlFormat.generate "mautrix-gmessages-registration.yaml" registrationBase; # LoadCredential directives for secure secret injection loadCredentials = [ "as_token:${cfg.appservice.asTokenFile}" "hs_token:${cfg.appservice.hsTokenFile}" ] ++ optional (cfg.appservice.provisioningSharedSecretFile != null) "provisioning_shared_secret:${cfg.appservice.provisioningSharedSecretFile}" ++ optional (cfg.matrix.loginSharedSecretFile != null) "login_shared_secret:${cfg.matrix.loginSharedSecretFile}"; in { options.services.matrix-vm.mautrix-gmessages = { enable = mkEnableOption "mautrix-gmessages Matrix-Google Messages bridge"; package = mkOption { type = types.package; default = pkgs-unstable.mautrix-gmessages; description = "Package providing the bridge executable."; }; user = mkOption { type = types.str; default = "mautrix_gmessages"; description = "System user for the bridge."; }; group = mkOption { type = types.str; default = "mautrix_gmessages"; description = "Primary group for the bridge."; }; dataDir = mkOption { type = types.path; default = "/var/lib/mautrix_gmessages"; description = "State directory."; }; runtimeDirectory = mkOption { type = types.str; default = "mautrix_gmessages"; description = "Runtime directory name under /run."; }; 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:6167"; description = "Matrix homeserver URL for bridge connections"; }; serverName = mkOption { type = types.str; description = "Matrix server name for bridge users"; }; verifySSL = mkOption { type = types.bool; default = true; description = "Verify SSL certificates when connecting to homeserver"; }; asmux = mkOption { type = types.bool; default = false; description = "Enable asmux protocol"; }; loginSharedSecretFile = mkOption { type = types.nullOr types.path; default = null; description = "Shared secret file for automatic double puppeting login"; }; }; # Database configuration database = { type = mkOption { type = types.str; default = "postgres"; description = "Database type (sqlite3 or postgres)"; }; uri = mkOption { type = types.str; default = "sqlite:///var/lib/mautrix_gmessages/gmessages.db?_txlock=immediate"; 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"; }; }; # Network configuration (formerly google_messages) network = { displaynameTemplate = mkOption { type = types.str; default = "{{or .FullName .PhoneNumber}}"; description = "Display name template for SMS users"; }; deviceMeta = { os = mkOption { type = types.str; default = "mautrix-gmessages"; description = "OS name shown in paired devices list"; }; browser = mkOption { type = types.enum ["OTHER" "CHROME" "FIREFOX" "SAFARI" "OPERA" "IE" "EDGE"]; default = "OTHER"; description = "Browser type shown to phone"; }; type = mkOption { type = types.enum ["WEB" "TABLET" "PWA"]; default = "TABLET"; description = "Device type - affects icon and session limits"; }; }; aggressiveReconnect = mkOption { type = types.bool; default = false; description = "Aggressively set bridge as active device if browser opens"; }; initialChatSyncCount = mkOption { type = types.int; default = 25; description = "Number of chats to sync when connecting"; }; }; # 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 = "!gm"; description = "Command prefix for bridge commands"; }; permissions = mkOption { type = types.attrsOf types.str; default = {}; description = "Bridge permissions mapping"; }; relay = { enabled = mkOption { type = types.bool; default = false; description = "Enable relay mode"; }; adminOnly = mkOption { type = types.bool; default = true; description = "Restrict relay to admins only"; }; }; captionInMessage = mkOption { type = types.bool; default = false; description = "Include captions in message body"; }; portalMessageBuffer = mkOption { type = types.int; default = 128; description = "Portal message buffer size"; }; additionalDoublePuppetServers = mkOption { type = types.attrsOf types.str; default = {}; description = "Additional servers for double puppeting"; }; allowDiscovery = mkOption { type = types.bool; default = true; description = "Allow discovery of double puppet servers"; }; }; # Appservice configuration appservice = { id = mkOption { type = types.str; default = "gmessages"; description = "Appservice ID"; }; hostname = mkOption { type = types.str; default = "127.0.0.1"; description = "Hostname for the appservice"; }; port = mkOption { type = types.port; default = 9556; description = "Port for the appservice API"; }; senderLocalpart = mkOption { type = types.str; default = "gmessagesbot"; description = "Localpart of the bridge bot user"; }; botDisplayName = mkOption { type = types.str; default = "Google Messages Bridge Bot"; description = "Display name for the bridge bot"; }; botAvatar = mkOption { type = types.str; default = "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak"; description = "Avatar MXC URI for the bridge bot"; }; userPrefix = mkOption { type = types.str; default = "_gmessages_"; description = "Prefix for Matrix users bridged from Google Messages"; }; aliasPrefix = mkOption { type = types.str; default = "gmessages_"; description = "Prefix for Matrix aliases/rooms"; }; publicAddress = mkOption { type = types.nullOr types.str; default = null; description = "Public URL for the bridge (used for media endpoints)."; }; pushEphemeral = mkOption { type = types.bool; default = true; description = "Enable push ephemeral events"; }; asTokenFile = mkOption { type = types.path; description = "Path to file containing appservice token"; }; hsTokenFile = mkOption { type = types.path; description = "Path to file containing homeserver token"; }; provisioningSharedSecretFile = mkOption { type = types.nullOr types.path; default = null; description = "Path to file containing provisioning shared secret"; }; }; # Backfill configuration backfill = { enable = mkOption { type = types.bool; default = false; description = "Enable message backfilling"; }; maxInitialMessages = mkOption { type = types.int; default = 50; description = "Maximum messages to backfill in empty rooms"; }; maxCatchupMessages = mkOption { type = types.int; default = 500; description = "Maximum missed messages to backfill after restart"; }; unreadHoursThreshold = mkOption { type = types.int; default = 720; description = "Mark old backfilled chats as read after this many hours"; }; }; # 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"; }; requireVerification = mkOption { type = types.bool; default = false; description = "Require device verification for encryption"; }; allowKeySharing = mkOption { type = types.bool; default = false; description = "Allow sharing encryption keys"; }; }; # Computed options (read-only) registrationFile = mkOption { type = types.str; readOnly = true; description = "Path to the generated registration file"; }; }; 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.members = lib.mkAfter [ cfg.user ]; # Systemd service with comprehensive hardening systemd.services.mautrix-gmessages = { description = "mautrix-gmessages Matrix-Google Messages bridge"; after = [ "network.target" "postgresql.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; SupplementaryGroups = [ "matrix-appservices" ]; PermissionsStartOnly = true; WorkingDirectory = cfg.dataDir; # Use StateDirectory for persistent config and data management StateDirectory = cfg.user; StateDirectoryMode = "0750"; LogsDirectory = cfg.user; # LoadCredential directives for secure secret injection LoadCredential = loadCredentials; # 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-gmessages-prepare-config" '' set -euo pipefail CONFIG_DIR=/var/lib/${cfg.user}/config CONFIG_PATH="$CONFIG_DIR/config.yaml" REG_PATH="$CONFIG_DIR/registration.yaml" cp ${exampleConfigPath} "$CONFIG_PATH" cp ${registrationFile} "$REG_PATH" ${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY' import json import os import pathlib import pwd import grp import shutil import yaml CONFIG_DIR = pathlib.Path('/var/lib/${cfg.user}/config') CONFIG_PATH = CONFIG_DIR / 'config.yaml' REG_PATH = CONFIG_DIR / 'registration.yaml' SHARED_REG = pathlib.Path('/var/lib/matrix-appservices/mautrix_gmessages_registration.yaml') with CONFIG_PATH.open('r', encoding='utf-8') as fh: config = yaml.safe_load(fh) with REG_PATH.open('r', encoding='utf-8') as fh: registration = 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: target.setdefault(key, {}) deep_update(target[key], value) else: target[key] = value deep_update(config, overrides) if extra_cfg: deep_update(config, extra_cfg) perms = overrides.get('bridge', {}).get('permissions') if perms is not None: config.setdefault('bridge', {})['permissions'] = perms dp_config = config.setdefault('double_puppet', {}) servers = overrides.get('double_puppet', {}).get('servers') if servers is not None: dp_config['servers'] = servers dp_config['secrets'] = {} creds_dir = os.environ.get('CREDENTIALS_DIRECTORY') as_token = hs_token = provisioning_secret = login_shared_secret = None if creds_dir: creds_path = pathlib.Path(creds_dir) def read_secret(name): path = creds_path / name return path.read_text(encoding='utf-8').strip() if path.exists() else None as_token = read_secret('as_token') hs_token = read_secret('hs_token') provisioning_secret = read_secret('provisioning_shared_secret') login_shared_secret = read_secret('login_shared_secret') if as_token: config.setdefault('appservice', {})['as_token'] = as_token registration['as_token'] = as_token if hs_token: config.setdefault('appservice', {})['hs_token'] = hs_token registration['hs_token'] = hs_token if overrides.get('appservice', {}).get('public_address') is None: config.setdefault('appservice', {})['public_address'] = None if provisioning_secret: config.setdefault('provisioning', {})['shared_secret'] = provisioning_secret else: config.setdefault('provisioning', {})['shared_secret'] = 'disable' if login_shared_secret: login_secret_value = login_shared_secret dp_config.setdefault('secrets', {})['${cfg.matrix.serverName}'] = login_secret_value with CONFIG_PATH.open('w', encoding='utf-8') as fh: yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False) with REG_PATH.open('w', encoding='utf-8') as fh: yaml.safe_dump(registration, 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) os.chown(REG_PATH, uid, gid_service) os.chmod(REG_PATH, 0o640) SHARED_REG.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(REG_PATH, SHARED_REG) os.chown(SHARED_REG, uid, gid_shared) os.chmod(SHARED_REG, 0o640) PY ''}" ]; # Start mautrix-gmessages with state directory config ExecStart = "${cfg.package}/bin/mautrix-gmessages -c /var/lib/${cfg.user}/config/config.yaml"; # Restart policy Restart = "always"; RestartSec = 10; # Security hardening following NixOS best practices # NoNewPrivileges = true; # Temporarily disabled to debug exit code 11 # ProtectSystem = "strict"; # Temporarily disabled to debug exit code 11 # ProtectHome = true; # Temporarily disabled to debug exit code 11 # PrivateTmp = true; # Temporarily disabled to debug exit code 11 # PrivateDevices = true; # Temporarily disabled to debug exit code 11 ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; # Allow writing to data, log, and shared appservices directory ReadWritePaths = [ cfg.dataDir "/var/log/${cfg.user}" "/var/lib/matrix-appservices" ]; # Network restrictions # RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; # Temporarily disabled to debug exit code 11 # System calls - Bridge needs broader access for Google Messages protocol SystemCallArchitectures = "native"; # SystemCallFilter = [ # "@system-service" # "@network-io" # "@file-system" # "@process" # "@io-event" # # Go runtime specific syscalls # "futex" # "clone" # "arch_prctl" # "mprotect" # "mmap" # "munmap" # "set_robust_list" # "sigaltstack" # "rt_sigreturn" # "rt_sigprocmask" # "sched_getaffinity" # # Required for chown/chmod operations in ExecStartPre # "fchownat" # "fchmodat" # ]; # Resource limits MemoryMax = "256M"; CPUWeight = 50; # Lower priority than Matrix server IOWeight = 50; # Process security - files created as 640 (owner rw, group r, other none) UMask = "0027"; LockPersonality = true; RestrictRealtime = true; RestrictSUIDSGID = true; RemoveIPC = true; # Logging StandardOutput = "journal"; StandardError = "journal"; SyslogIdentifier = "mautrix-gmessages"; }; # Add extra packages to PATH if specified path = [ pkgs.coreutils pkgs.gnused ] ++ cfg.extraPackages; }; # Directory permissions systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -" "Z ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -" "d /var/log/${cfg.user} 0750 ${cfg.user} ${cfg.group} -" "Z /var/log/${cfg.user} 0750 ${cfg.user} ${cfg.group} - -" "d /var/lib/matrix-appservices 0775 root matrix-appservices -" ]; # Open firewall port for AS API networking.firewall.allowedTCPPorts = [ cfg.appservice.port ]; # Set registration file path for integration with Matrix server (shared location) services.matrix-vm.mautrix-gmessages.registrationFile = "/var/lib/matrix-appservices/mautrix_gmessages_registration.yaml"; }; }