ops-jrz1/modules/mautrix-gmessages.nix
Dan ab5aebb161 Phase 3: Extract and sanitize Matrix platform modules from ops-base
Extracted modules:
- Matrix homeserver (matrix-continuwuity.nix)
- mautrix bridges (slack, whatsapp, gmessages)
- Security modules (fail2ban, ssh-hardening)
- Development services module
- Matrix secrets module

All modules sanitized to remove personal information:
- Domains: example.com, matrix.example.org
- IPs: 10.0.0.x, 203.0.113.10
- Paths: /home/user, /path/to/ops-base
- Emails: admin@example.com

Configuration:
- Updated flake.nix with sops-nix and nixpkgs-unstable
- Updated hosts/ops-jrz1.nix to import all extracted modules
- Added example files (secrets, minimal config)
- Generated flake.lock

Generated with Claude Code - https://claude.com/claude-code
2025-10-13 14:51:14 -07:00

713 lines
21 KiB
Nix

# mautrix-gmessages Matrix-Google Messages bridge
# Bridges Google Messages (RCS/SMS/MMS) to Matrix via web interface
{ config, pkgs, 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.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";
};
}