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.
572 lines
18 KiB
Nix
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;
|
|
};
|
|
};
|
|
};
|
|
}
|