ops-jrz1/modules/mautrix-whatsapp.nix
Dan 4c38331e17 Fix Matrix package references to use nixpkgs-unstable
Matrix packages (mautrix-*, matrix-continuwuity) only exist in
nixpkgs-unstable, not in nixpkgs 24.05 stable. This commit updates
all module defaults and references to use pkgs-unstable.

Changes:
- Add pkgs-unstable to module function signatures (4 modules)
- Update package option defaults from pkgs.* to pkgs-unstable.*
- Configure pkgs-unstable in flake.nix to permit olm-3.2.16
- Add VM config permittedInsecurePackages for olm (mautrix dependency)

The olm library is deprecated with known CVEs but required by mautrix
bridges. This is acceptable for testing; production should migrate to
newer cryptography implementations when available.

This maintains our stable base system (NixOS 24.05) while using
unstable only for Matrix ecosystem packages under active development.
2025-10-21 00:06:43 -07:00

572 lines
18 KiB
Nix

{ config, pkgs, pkgs-unstable, lib, ... }:
with lib;
let
cfg = config.services.matrix-vm.mautrix-whatsapp;
yamlFormat = pkgs.formats.yaml { };
registrationDir = builtins.dirOf (toString cfg.appservice.registrationFile);
baseConfig = {
homeserver = {
address = cfg.matrix.homeserverUrl;
domain = cfg.matrix.serverName;
verify_ssl = cfg.matrix.verifySSL;
};
database = {
type = cfg.database.type;
uri = cfg.database.uri;
max_open_conns = cfg.database.maxOpenConnections;
max_idle_conns = cfg.database.maxIdleConnections;
};
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;
};
public_address = cfg.appservice.publicAddress;
username_template = "${cfg.appservice.userPrefix}{{.}}";
protocols = cfg.appservice.protocols;
"de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral;
};
network = {
os_name = cfg.network.osName;
browser_name = cfg.network.browserName;
direct_media_auto_request = cfg.network.directMediaAutoRequest;
initial_auto_reconnect = cfg.network.initialAutoReconnect;
history_sync = {
max_initial_conversations = cfg.network.historySync.maxInitialConversations;
request_full_sync = cfg.network.historySync.requestFullSync;
dispatch_wait = cfg.network.historySync.dispatchWait;
media_requests = {
auto_request_media = cfg.network.historySync.mediaRequests.autoRequest;
request_method = cfg.network.historySync.mediaRequests.requestMethod;
request_local_time = cfg.network.historySync.mediaRequests.requestLocalTime;
max_async_handle = cfg.network.historySync.mediaRequests.maxAsyncHandle;
};
};
};
bridge = {
command_prefix = cfg.bridge.commandPrefix;
personal_filtering_spaces = cfg.bridge.personalFilteringSpaces;
bridge_status_notices = cfg.bridge.bridgeStatusNotices;
permissions = cfg.bridge.permissions;
cleanup_on_logout = {
enabled = cfg.bridge.cleanupOnLogout != "nothing";
};
};
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;
allow_key_sharing = cfg.encryption.allowKeySharing;
};
analytics = {
token = null;
};
provisioning = {
shared_secret = if cfg.appservice.provisioningSharedSecretFile != null then "__credential__" else "disable";
};
};
configBaseFile = yamlFormat.generate "mautrix-whatsapp-config-base.yaml" baseConfig;
registrationBase = {
id = cfg.appservice.id;
url = "http://${cfg.appservice.hostname}:${toString cfg.appservice.port}";
sender_localpart = cfg.appservice.senderLocalpart;
rate_limited = false;
namespaces = {
users = [{
regex = "@${cfg.appservice.userPrefix}.+:${cfg.matrix.serverName}";
exclusive = true;
}];
aliases = [{
regex = "#${cfg.appservice.aliasPrefix}.*:${cfg.matrix.serverName}";
exclusive = true;
}];
rooms = [];
};
"de.sorunome.msc2409.push_ephemeral" = cfg.appservice.pushEphemeral;
};
registrationBaseFile = yamlFormat.generate "mautrix-whatsapp-registration-base.yaml" registrationBase;
extraConfigFile = optionalString (cfg.extraConfig != { })
(toString (yamlFormat.generate "mautrix-whatsapp-extra.yaml" cfg.extraConfig));
generateConfigScript = pkgs.writeShellScript "generate-mautrix-whatsapp-config" ''
set -euo pipefail
CONFIG_OUT="/run/${cfg.runtimeDirectory}/config.yaml"
REG_OUT="${cfg.appservice.registrationFile}"
# Runtime directories created by systemd.tmpfiles.d
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 <<'PY'
import os
import pathlib
import pwd
import grp
import yaml
base_config_path = pathlib.Path("${configBaseFile}")
config_output = pathlib.Path("/run/${cfg.runtimeDirectory}/config.yaml")
registration_base_path = pathlib.Path("${registrationBaseFile}")
registration_output = pathlib.Path("${cfg.appservice.registrationFile}")
extra_config_path = "${extraConfigFile}"
with base_config_path.open("r", encoding="utf-8") as fh:
config = yaml.safe_load(fh)
if extra_config_path:
with open(extra_config_path, "r", encoding="utf-8") as fh:
extra_cfg = yaml.safe_load(fh) or {}
def deep_merge(base, extra):
for key, value in extra.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
deep_merge(base[key], value)
else:
base[key] = value
return base
deep_merge(config, extra_cfg)
creds_dir = pathlib.Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
if not creds_dir:
raise SystemExit("CREDENTIALS_DIRECTORY not provided")
def read_required(name):
path = creds_dir / name
if not path.exists():
raise SystemExit(f"Missing credential {name}")
return path.read_text(encoding="utf-8").strip()
def read_optional(name):
path = creds_dir / name
if path.exists():
return path.read_text(encoding="utf-8").strip()
return None
as_token = read_required("as_token")
hs_token = read_required("hs_token")
login_secret = read_optional("login_shared_secret")
provisioning_secret = read_optional("provisioning_shared_secret")
config.setdefault("double_puppet", {}).setdefault("secrets", {})
if login_secret:
config["double_puppet"]["secrets"]["${cfg.matrix.serverName}"] = login_secret
if provisioning_secret:
config.setdefault("provisioning", {})["shared_secret"] = provisioning_secret
else:
config.setdefault("provisioning", {})["shared_secret"] = "disable"
with registration_base_path.open("r", encoding="utf-8") as fh:
registration = yaml.safe_load(fh)
registration["as_token"] = as_token
registration["hs_token"] = hs_token
# Add tokens to config appservice section
config.setdefault("appservice", {})["as_token"] = as_token
config.setdefault("appservice", {})["hs_token"] = hs_token
config_output.parent.mkdir(parents=True, exist_ok=True)
with config_output.open("w", encoding="utf-8") as fh:
yaml.safe_dump(config, fh, default_flow_style=False, sort_keys=False)
registration_output.parent.mkdir(parents=True, exist_ok=True)
with registration_output.open("w", encoding="utf-8") as fh:
yaml.safe_dump(registration, fh, default_flow_style=False, sort_keys=False)
svc_uid = pwd.getpwnam("${cfg.user}").pw_uid
svc_gid = grp.getgrnam("${cfg.group}").gr_gid
reg_uid = pwd.getpwnam("${cfg.appservice.registrationUser}").pw_uid
reg_gid = grp.getgrnam("${cfg.appservice.registrationGroup}").gr_gid
os.chown(config_output, svc_uid, svc_gid)
os.chmod(config_output, 0o640)
os.chown(registration_output, reg_uid, reg_gid)
os.chmod(registration_output, 0o640)
PY
'';
loadCredentials =
[
"as_token:${cfg.appservice.asTokenFile}"
"hs_token:${cfg.appservice.hsTokenFile}"
]
++ optional (cfg.matrix.loginSharedSecretFile != null)
"login_shared_secret:${cfg.matrix.loginSharedSecretFile}"
++ optional (cfg.appservice.provisioningSharedSecretFile != null)
"provisioning_shared_secret:${cfg.appservice.provisioningSharedSecretFile}";
in
{
options.services.matrix-vm.mautrix-whatsapp = {
enable = mkEnableOption "mautrix-whatsapp bridge";
package = mkOption {
type = types.package;
default = pkgs-unstable.mautrix-whatsapp;
description = "Package providing the bridge executable.";
};
user = mkOption {
type = types.str;
default = "mautrix_whatsapp";
description = "System user for the bridge.";
};
group = mkOption {
type = types.str;
default = "mautrix_whatsapp";
description = "Primary group for the bridge.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/mautrix_whatsapp";
description = "State directory.";
};
runtimeDirectory = mkOption {
type = types.str;
default = "mautrix_whatsapp";
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 = {
homeserverUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:6167";
description = "Homeserver URL (bridge -> HS).";
};
serverName = mkOption {
type = types.str;
default = "example.com";
description = "Matrix server name.";
};
verifySSL = mkOption {
type = types.bool;
default = false;
description = "Verify TLS for homeserver requests.";
};
loginSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Shared secret file for automatic double puppeting.";
};
};
database = {
type = mkOption {
type = types.enum [ "postgres" "sqlite3-fk-wal" ];
default = "postgres";
description = "Database backend.";
};
uri = mkOption {
type = types.str;
default = "postgresql:///mautrix_whatsapp?host=/run/postgresql";
description = "Database connection string.";
};
maxOpenConnections = mkOption {
type = types.ints.positive;
default = 10;
description = "Maximum open DB connections.";
};
maxIdleConnections = mkOption {
type = types.ints.unsigned;
default = 2;
description = "Maximum idle DB connections.";
};
};
appservice = {
id = mkOption {
type = types.str;
default = "whatsapp";
description = "Appservice ID.";
};
senderLocalpart = mkOption {
type = types.str;
default = "whatsappbot";
description = "Bot localpart.";
};
botDisplayName = mkOption {
type = types.nullOr types.str;
default = "WhatsApp bridge";
description = "Bot display name.";
};
botAvatar = mkOption {
type = types.nullOr types.str;
default = "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr";
description = "Bot avatar MXC URI.";
};
hostname = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Bind address for bridge HTTP listener.";
};
port = mkOption {
type = types.port;
default = 29318;
description = "Port for bridge HTTP listener.";
};
publicAddress = mkOption {
type = types.nullOr types.str;
default = null;
description = "Public URL for bridge (optional).";
};
asTokenFile = mkOption {
type = types.path;
description = "Path containing the appservice as_token.";
};
hsTokenFile = mkOption {
type = types.path;
description = "Path containing the appservice hs_token.";
};
provisioningSharedSecretFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Provisioning API shared secret file (optional).";
};
registrationFile = mkOption {
type = types.path;
default = "/var/lib/matrix-appservices/mautrix_whatsapp_registration.yaml";
description = "Generated registration file path.";
};
registrationUser = mkOption {
type = types.str;
default = cfg.user;
description = "Owner user for the registration file.";
};
registrationGroup = mkOption {
type = types.str;
default = cfg.group;
description = "Owner group for the registration file.";
};
userPrefix = mkOption {
type = types.str;
default = "whatsapp_";
description = "MXID prefix for puppets.";
};
aliasPrefix = mkOption {
type = types.str;
default = "whatsapp_";
description = "Alias prefix for portals.";
};
protocols = mkOption {
type = types.listOf types.str;
default = [];
description = "Optional MSC3079 protocol identifiers.";
};
pushEphemeral = mkOption {
type = types.bool;
default = false;
description = "Enable MSC2409 push ephemeral events.";
};
};
network = {
osName = mkOption {
type = types.str;
default = "mautrix-whatsapp";
description = "Device name shown in WhatsApp linked devices.";
};
browserName = mkOption {
type = types.str;
default = "unknown";
description = "Browser indicator shown in WhatsApp linked devices.";
};
directMediaAutoRequest = mkOption {
type = types.bool;
default = true;
description = "Automatically request expired media during backfill.";
};
initialAutoReconnect = mkOption {
type = types.bool;
default = true;
description = "Retry connection failures automatically on startup.";
};
historySync = {
maxInitialConversations = mkOption {
type = types.int;
default = -1;
description = "Chats to create on initial history sync (-1 = all).";
};
requestFullSync = mkOption {
type = types.bool;
default = false;
description = "Request a full-year history sync on login.";
};
dispatchWait = mkOption {
type = types.str;
default = "1m";
description = "Wait time for history payloads before backfill.";
};
mediaRequests = {
autoRequest = mkOption {
type = types.bool;
default = true;
description = "Automatically request expired media.";
};
requestMethod = mkOption {
type = types.enum [ "immediate" "local_time" ];
default = "immediate";
description = "Scheduling mode for automatic media requests.";
};
requestLocalTime = mkOption {
type = types.int;
default = 120;
description = "Minutes after midnight when using local_time scheduling.";
};
maxAsyncHandle = mkOption {
type = types.ints.positive;
default = 2;
description = "Maximum concurrent media requests per user.";
};
};
};
};
bridge = {
commandPrefix = mkOption {
type = types.str;
default = "!wa";
description = "Command prefix for management commands.";
};
personalFilteringSpaces = mkOption {
type = types.bool;
default = true;
description = "Create personal spaces for each login.";
};
bridgeStatusNotices = mkOption {
type = types.enum [ "none" "errors" "all" ];
default = "errors";
description = "Status notice verbosity.";
};
cleanupOnLogout = mkOption {
type = types.enum [ "nothing" "kick" "unbridge" "delete" ];
default = "nothing";
description = "Portal cleanup behaviour on logout.";
};
permissions = mkOption {
type = types.attrsOf (types.enum [ "relay" "commands" "user" "admin" ]);
default = { "*" = "relay"; };
description = "Permission mapping for users/domains.";
};
allowDiscovery = mkOption {
type = types.bool;
default = false;
description = "Allow double puppeting discovery for remote homeservers.";
};
additionalDoublePuppetServers = mkOption {
type = types.attrsOf types.str;
default = {};
description = "Extra homeservers permitted for double puppeting (domain -> client API URL).";
};
};
encryption = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable end-to-bridge encryption.";
};
default = mkOption {
type = types.bool;
default = false;
description = "Enable encryption in newly created portals.";
};
require = mkOption {
type = types.bool;
default = false;
description = "Drop plaintext messages in encrypted rooms.";
};
allowKeySharing = mkOption {
type = types.bool;
default = true;
description = "Fulfil inbound key requests.";
};
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = false;
};
users.groups.${cfg.group} = {};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
"d ${registrationDir} 2770 root matrix-appservices -"
"d /run/${cfg.runtimeDirectory} 0750 ${cfg.user} ${cfg.group} -"
];
systemd.services.mautrix-whatsapp = {
description = "mautrix-whatsapp Matrix bridge";
after = [ "network.target" "network-online.target" ]
++ optional (cfg.database.type == "postgres") "postgresql.service";
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ cfg.package ] ++ cfg.extraPackages;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
# Runtime directory created by systemd.tmpfiles.d
ExecStartPre = generateConfigScript;
ExecStart = "${cfg.package}/bin/mautrix-whatsapp -c /run/${cfg.runtimeDirectory}/config.yaml";
Restart = "always";
RestartSec = "10s";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictSUIDSGID = true;
RestrictRealtime = true;
LockPersonality = true;
UMask = "0002";
SystemCallArchitectures = "native";
ReadWritePaths = [ cfg.dataDir cfg.appservice.registrationFile registrationDir ];
LoadCredential = loadCredentials;
};
};
};
}