nixos/hosts/fw/modules/web/matrix.nix

633 lines
20 KiB
Nix

{ pkgs, lib, config, ... }:
let
hostname = "matrix";
fqdn = "${hostname}.cloonar.com";
baseUrl = "https://${fqdn}";
clientConfig = {
"m.homeserver".base_url = baseUrl;
# MAS auth issuer discovery (MSC2965)
"org.matrix.msc2965.authentication" = {
issuer = baseUrl + "/";
account = baseUrl + "/account";
};
# MatrixRTC LiveKit focus for Element Call
"org.matrix.msc4143.rtc_foci" = [
{
type = "livekit";
livekit_service_url = "${baseUrl}/livekit/jwt";
}
];
};
serverConfig."m.server" = "${fqdn}:443";
mkWellKnown = data: ''
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '${builtins.toJSON data}';
'';
masUpstreamId = "01KJPRKN397E5N8D0CA2Z3TJ7Y";
elementWebClientId = "01KJPVT5D54NRAY7AJY6PZEN0D";
masPackage = pkgs.matrix-authentication-service;
synapseMasConfig = pkgs.writeText "synapse-mas-config.yaml" ''
matrix_authentication_service:
enabled: true
endpoint: "http://127.0.0.1:8081"
secret_path: ${config.sops.secrets.mas-matrix-secret-synapse.path}
'';
synapseVoipConfig = "/run/matrix-synapse/voip-config.yaml";
doublePuppetRegistration = "/run/matrix-synapse/double-puppet-registration.yaml";
in {
# Secrets for MAS
sops.secrets.mas-encryption-key = { owner = "mas"; };
sops.secrets.mas-matrix-secret = { owner = "mas"; };
sops.secrets.mas-authelia-client-secret = { owner = "mas"; };
sops.secrets.mas-rsa-key = { owner = "mas"; };
# Synapse also needs the shared secret
sops.secrets.mas-matrix-secret-synapse = {
owner = "matrix-synapse";
key = "mas-matrix-secret";
};
# Double puppet appservice tokens (for bridge double puppeting via MAS)
sops.secrets.double-puppet-as-token = { owner = "matrix-synapse"; };
sops.secrets.double-puppet-hs-token = { owner = "matrix-synapse"; };
# TURN shared secret (for Synapse VoIP config)
sops.secrets.coturn-static-secret = {
sopsFile = ./secrets.yaml;
owner = "matrix-synapse";
};
sops.secrets.mautrix-whatsapp-env = { };
sops.secrets.mautrix-signal-env = { };
sops.secrets.mautrix-discord-env = { };
sops.secrets.mautrix-mattermost-env = { };
# MAS system user
users.users.mas = {
isSystemUser = true;
group = "mas";
home = "/var/lib/mas";
};
users.groups.mas = { };
# PostgreSQL databases for Synapse and MAS
services.postgresql = {
enable = true;
# Synapse requires C locale for correct collation behavior
initdbArgs = [ "--lc-collate=C" "--lc-ctype=C" ];
ensureDatabases = [ "matrix-synapse" "mas" ];
ensureUsers = [
{
name = "matrix-synapse";
ensureDBOwnership = true;
}
{
name = "mas";
ensureDBOwnership = true;
}
];
};
services.postgresqlBackup.enable = true;
services.postgresqlBackup.databases = [ "matrix-synapse" "mas" ];
# Matrix Authentication Service (MAS)
systemd.services.matrix-authentication-service = {
description = "Matrix Authentication Service";
after = [ "postgresql.service" "network.target" ];
before = [ "matrix-synapse.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "mas";
Group = "mas";
RuntimeDirectory = "mas";
RuntimeDirectoryMode = "0755";
StateDirectory = "mas";
StateDirectoryMode = "0750";
ExecStart = "${masPackage}/bin/mas-cli server --config /run/mas/config.yaml";
Restart = "on-failure";
RestartSec = "5s";
};
preStart = ''
# Read secrets from SOPS-managed files
ENCRYPTION_KEY=$(cat ${config.sops.secrets.mas-encryption-key.path})
MATRIX_SECRET=$(cat ${config.sops.secrets.mas-matrix-secret.path})
CLIENT_SECRET=$(cat ${config.sops.secrets.mas-authelia-client-secret.path})
# Write MAS config with secrets interpolated
cat > /run/mas/config.yaml <<MASEOF
http:
public_base: ${baseUrl}/
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
- name: assets
binds:
- address: "127.0.0.1:8081"
database:
uri: postgresql:///mas?host=/run/postgresql
matrix:
homeserver: cloonar.com
endpoint: "http://[::1]:8008"
secret: "$MATRIX_SECRET"
upstream_oauth2:
providers:
- id: ${masUpstreamId}
synapse_idp_id: oidc-authelia
human_name: Authelia
issuer: https://auth.cloonar.com
client_id: synapse
client_secret: "$CLIENT_SECRET"
token_endpoint_auth_method: client_secret_post
scope: "openid email profile"
fetch_userinfo: true
claims_imports:
skip_confirmation: true
localpart:
action: require
template: "{{ user.email | replace('@cloonar.com', ''') | replace('@', '_') }}"
displayname:
action: force
template: "{{ user.name }}"
email:
action: require
template: "{{ user.email }}"
set_email_verification: always
clients:
- client_id: ${elementWebClientId}
client_auth_method: none
redirect_uris:
- https://element.cloonar.com/
- https://element.cloonar.com/?no_universal_links=true
passwords:
enabled: true
schemes:
- version: 1
algorithm: bcrypt
secrets:
encryption: "$ENCRYPTION_KEY"
keys:
- kid: mas-rsa-key
key_file: ${config.sops.secrets.mas-rsa-key.path}
telemetry:
tracing:
exporter: none
metrics:
exporter: none
MASEOF
'';
};
# Synapse homeserver
services.matrix-synapse = {
enable = true;
extraConfigFiles = [ "${synapseMasConfig}" synapseVoipConfig ];
settings = {
server_name = "cloonar.com";
public_baseurl = baseUrl;
listeners = [
{
port = 8008;
bind_addresses = [ "::1" ];
type = "http";
tls = false;
x_forwarded = true;
resources = [
{
compress = true;
names = [ "client" "federation" ];
}
];
}
];
database = {
name = "psycopg2";
args = {
host = "/run/postgresql";
database = "matrix-synapse";
user = "matrix-synapse";
};
};
allow_guest_access = false;
# Double puppet appservice registration (bridges use this for m.login.application_service)
app_service_config_files = [ doublePuppetRegistration ];
# MSC4190: device management for appservices (required for encrypted bridges with MAS)
experimental_features = {
msc4190_enabled = true;
msc3202_device_masquerading = true;
# MatrixRTC support
msc3266_enabled = true;
msc4222_enabled = true;
# QR code login (MSC4108) — enables the built-in rendezvous server
msc4108_enabled = true;
};
# MatrixRTC delayed events (MSC4140) — prevents stuck calls
max_event_delay_duration = "24h";
};
};
# Synapse runs inside an isolated microVM, so PrivateUsers provides minimal
# additional security. Disabling it allows Synapse to read bridge registration
# files via SupplementaryGroups (user namespace blocks mapped GIDs otherwise).
# Synapse depends on MAS for auth delegation
systemd.services.matrix-synapse.after = [ "matrix-authentication-service.service" ];
systemd.services.matrix-synapse.wants = [ "matrix-authentication-service.service" ];
systemd.services.matrix-synapse.serviceConfig.PrivateUsers = lib.mkForce false;
systemd.services.matrix-synapse.preStart = lib.mkAfter ''
install -m 0600 /dev/null ${synapseVoipConfig}
TURN_SECRET=$(cat ${config.sops.secrets.coturn-static-secret.path})
cat > ${synapseVoipConfig} <<EOF
turn_uris:
- "turns:turn.cloonar.com?transport=udp"
- "turns:turn.cloonar.com?transport=tcp"
- "turn:turn.cloonar.com?transport=udp"
- "turn:turn.cloonar.com?transport=tcp"
turn_shared_secret: "$TURN_SECRET"
turn_user_lifetime: 86400000
EOF
install -m 0640 /dev/null ${doublePuppetRegistration}
DP_AS=$(cat ${config.sops.secrets.double-puppet-as-token.path})
DP_HS=$(cat ${config.sops.secrets.double-puppet-hs-token.path})
cat > ${doublePuppetRegistration} <<DPEOF
id: double-puppet
as_token: "$DP_AS"
hs_token: "$DP_HS"
url:
sender_localpart: _double-puppet
rate_limited: false
namespaces:
users:
- regex: '@.*:cloonar\.com'
exclusive: false
DPEOF
'';
# Generate env file with double puppet token for bridges
systemd.services.generate-double-puppet-env = {
description = "Generate double puppet environment file";
wantedBy = [ "multi-user.target" ];
before = [
"mautrix-whatsapp.service"
"mautrix-signal.service"
"mautrix-discord-registration.service"
"mautrix-mattermost-registration.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
echo "DOUBLE_PUPPET_AS_TOKEN=$(cat ${config.sops.secrets.double-puppet-as-token.path})" > /run/double-puppet-env
chmod 0444 /run/double-puppet-env
'';
};
# Append double puppet env file to each bridge's EnvironmentFile
# WhatsApp & Signal: envsubst runs in main service preStart
systemd.services.mautrix-whatsapp.serviceConfig.EnvironmentFile = [ "/run/double-puppet-env" ];
systemd.services.mautrix-signal.serviceConfig.EnvironmentFile = [ "/run/double-puppet-env" ];
# Discord & Mattermost: envsubst runs in registration service script
systemd.services.mautrix-discord-registration.serviceConfig.EnvironmentFile = [ "/run/double-puppet-env" ];
systemd.services.mautrix-mattermost-registration.serviceConfig.EnvironmentFile = [ "/run/double-puppet-env" ];
# Element Web client
services.nginx.virtualHosts."element.cloonar.com" = {
forceSSL = true;
enableACME = true;
acmeRoot = null;
root = pkgs.element-web.override {
conf = {
default_theme = "dark";
default_server_config = {
"m.homeserver" = {
base_url = "https://matrix.cloonar.com";
server_name = "cloonar.com";
};
"org.matrix.msc2965.authentication" = {
issuer = "https://matrix.cloonar.com/";
account = "https://matrix.cloonar.com/account";
};
"org.matrix.msc4143.rtc_foci" = [
{
type = "livekit";
livekit_service_url = "https://matrix.cloonar.com/livekit/jwt";
}
];
};
oidc_static_clients = {
"https://matrix.cloonar.com/" = {
client_id = elementWebClientId;
};
};
disable_custom_urls = true;
disable_3pid_login = true;
default_country_code = "AT";
};
};
};
# Synapse + MAS nginx reverse proxy
services.nginx.virtualHosts."${fqdn}" = {
forceSSL = true;
enableACME = true;
acmeRoot = null;
locations."/".extraConfig = ''
return 404;
'';
locations."= /.well-known/matrix/server".extraConfig = mkWellKnown serverConfig;
locations."= /.well-known/matrix/client".extraConfig = mkWellKnown clientConfig;
# MAS compatibility endpoints (must be before /_matrix catch-all)
locations."~ ^/_matrix/client/(r0|v3)/login".proxyPass = "http://127.0.0.1:8081";
locations."~ ^/_matrix/client/(r0|v3)/logout".proxyPass = "http://127.0.0.1:8081";
locations."~ ^/_matrix/client/(r0|v3)/refresh$".proxyPass = "http://127.0.0.1:8081";
# MAS own endpoints
locations."/authorize".proxyPass = "http://127.0.0.1:8081";
locations."/oauth2".proxyPass = "http://127.0.0.1:8081";
locations."/.well-known/openid-configuration".proxyPass = "http://127.0.0.1:8081";
locations."/.well-known/webfinger".proxyPass = "http://127.0.0.1:8081";
locations."/assets".proxyPass = "http://127.0.0.1:8081";
locations."/graphql".proxyPass = "http://127.0.0.1:8081";
locations."/account".proxyPass = "http://127.0.0.1:8081";
locations."/upstream".proxyPass = "http://127.0.0.1:8081";
locations."/register".proxyPass = "http://127.0.0.1:8081";
locations."/consent".proxyPass = "http://127.0.0.1:8081";
locations."/recovery".proxyPass = "http://127.0.0.1:8081";
locations."/login".proxyPass = "http://127.0.0.1:8081";
locations."/change-password".proxyPass = "http://127.0.0.1:8081";
locations."/complete-compat-sso".proxyPass = "http://127.0.0.1:8081";
locations."/logout".proxyPass = "http://127.0.0.1:8081";
# LiveKit JWT service for MatrixRTC
locations."^~ /livekit/jwt/" = {
proxyPass = "http://127.0.0.1:8082/";
};
# LiveKit SFU WebSocket
locations."^~ /livekit/sfu/" = {
proxyPass = "http://127.0.0.1:7880/";
proxyWebsockets = true;
extraConfig = ''
proxy_send_timeout 120;
proxy_read_timeout 120;
proxy_buffering off;
'';
};
# Synapse endpoints
locations."/_matrix".proxyPass = "http://[::1]:8008";
locations."/_synapse/client" = {
proxyPass = "http://[::1]:8008";
extraConfig = ''
# MSC4108 rendezvous relies on strong ETag comparison;
# gzip can break it, so disable compression here.
gzip off;
'';
};
locations."/_synapse/mas".proxyPass = "http://[::1]:8008";
};
# Internal proxy for bridges: routes login/auth to MAS, everything else to Synapse.
# Bridges connect here instead of directly to Synapse, which no longer serves
# /_matrix/client/v3/login when MAS is enabled.
services.nginx.virtualHosts."matrix-internal" = {
listen = [{ addr = "127.0.0.1"; port = 8009; }];
locations."~ ^/_matrix/client/(r0|v3)/login".proxyPass = "http://127.0.0.1:8081";
locations."~ ^/_matrix/client/(r0|v3)/logout".proxyPass = "http://127.0.0.1:8081";
locations."~ ^/_matrix/client/(r0|v3)/refresh$".proxyPass = "http://127.0.0.1:8081";
locations."/_matrix".proxyPass = "http://[::1]:8008";
locations."/_synapse/client".proxyPass = "http://[::1]:8008";
};
#
# Mautrix bridges (using NixOS modules)
# Modules handle users, groups, registration files, Synapse integration,
# and service ordering automatically via registerToSynapse.
#
# WhatsApp bridge
services.mautrix-whatsapp = {
enable = true;
registerToSynapse = true;
environmentFile = config.sops.secrets.mautrix-whatsapp-env.path;
settings = {
homeserver = {
address = "http://127.0.0.1:8009";
domain = "cloonar.com";
};
bridge = {
command_prefix = "!wa";
permissions."*" = "relay";
permissions."cloonar.com" = "user";
relay.enabled = true;
};
double_puppet = {
servers."cloonar.com" = "http://127.0.0.1:8009";
secrets."cloonar.com" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
};
encryption = {
allow = true;
default = true;
require = true;
self_sign = true;
pickle_key = "$MAUTRIX_WHATSAPP_PICKLE_KEY";
msc4190 = true;
};
};
};
# Signal bridge
services.mautrix-signal = {
enable = true;
registerToSynapse = true;
environmentFile = config.sops.secrets.mautrix-signal-env.path;
settings = {
homeserver = {
address = "http://127.0.0.1:8009";
domain = "cloonar.com";
};
bridge = {
command_prefix = "!signal";
permissions."*" = "relay";
permissions."cloonar.com" = "user";
relay.enabled = true;
};
double_puppet = {
servers."cloonar.com" = "http://127.0.0.1:8009";
secrets."cloonar.com" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
};
encryption = {
allow = true;
default = true;
require = true;
self_sign = true;
pickle_key = "$MAUTRIX_SIGNAL_PICKLE_KEY";
msc4190 = true;
};
matrix.sync_direct_chat_list = true;
};
};
# Discord bridge
services.mautrix-discord = {
enable = true;
registerToSynapse = true;
environmentFile = config.sops.secrets.mautrix-discord-env.path;
settings = {
homeserver = {
address = "http://127.0.0.1:8009";
domain = "cloonar.com";
};
bridge = {
command_prefix = "!discord";
permissions."*" = "relay";
permissions."cloonar.com" = "user";
relay.enabled = true;
};
double_puppet = {
servers."cloonar.com" = "http://127.0.0.1:8009";
secrets."cloonar.com" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
};
# Override token defaults so env var substitution writes real tokens.
# Must include database/address/port since setting appservice replaces the whole default.
appservice = {
address = "http://localhost:29334";
hostname = "0.0.0.0";
port = 29334;
database = {
type = "sqlite3";
uri = "file:/var/lib/mautrix-discord/mautrix-discord.db?_txlock=immediate";
};
id = "discord";
bot.username = "discordbot";
as_token = "$MAUTRIX_DISCORD_AS_TOKEN";
hs_token = "$MAUTRIX_DISCORD_HS_TOKEN";
};
encryption = {
allow = true;
default = true;
require = true;
self_sign = true;
pickle_key = "$MAUTRIX_DISCORD_PICKLE_KEY";
msc4190 = true;
};
};
};
#
# LiveKit SFU + JWT service for MatrixRTC video/voice calls
#
# LiveKit SFU — handles WebRTC media relay
services.livekit = {
enable = true;
openFirewall = true;
keyFile = "/run/livekit/key";
settings = {
rtc = {
port_range_start = 50000;
port_range_end = 50200;
use_external_ip = true;
};
room.auto_create = false;
};
};
# JWT service — validates Matrix OpenID tokens and issues LiveKit JWTs
services.lk-jwt-service = {
enable = true;
livekitUrl = "wss://${fqdn}/livekit/sfu";
port = 8082;
keyFile = "/run/livekit/key";
};
# Only allow cloonar.com users to create LiveKit rooms
systemd.services.lk-jwt-service.environment.LIVEKIT_FULL_ACCESS_HOMESERVERS = "cloonar.com";
# Generate LiveKit API key on boot
systemd.services.livekit-key-generate = {
description = "Generate LiveKit API key";
before = [ "livekit.service" "lk-jwt-service.service" ];
requiredBy = [ "livekit.service" "lk-jwt-service.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [ ! -f /run/livekit/key ]; then
mkdir -p /run/livekit
SECRET=$(${pkgs.openssl}/bin/openssl rand -hex 32)
echo "lk-jwt-service: $SECRET" > /run/livekit/key
chmod 0644 /run/livekit/key
fi
'';
};
# Mattermost bridge (bridgev2 — attrs replace entirely, so include all needed fields)
services.mautrix-mattermost = {
enable = true;
registerToSynapse = true;
environmentFile = config.sops.secrets.mautrix-mattermost-env.path;
settings = {
homeserver = {
address = "http://127.0.0.1:8009";
domain = "cloonar.com";
};
bridge = {
command_prefix = "!mm";
permissions."*" = "relay";
permissions."cloonar.com" = "user";
relay.enabled = true;
};
double_puppet = {
servers."cloonar.com" = "http://127.0.0.1:8009";
secrets."cloonar.com" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
};
appservice = {
address = "http://localhost:29335";
hostname = "0.0.0.0";
port = 29335;
id = "mattermost";
bot.username = "mattermostbot";
ephemeral_events = true;
username_template = "mattermost_{{.}}";
as_token = "$MAUTRIX_MATTERMOST_AS_TOKEN";
hs_token = "$MAUTRIX_MATTERMOST_HS_TOKEN";
};
encryption = {
allow = true;
default = true;
require = true;
self_sign = true;
pickle_key = "$MAUTRIX_MATTERMOST_PICKLE_KEY";
msc4190 = true;
};
};
};
}