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