diff --git a/sp-modules/matrix/config-paths-needed.json b/sp-modules/matrix/config-paths-needed.json new file mode 100644 index 0000000..1b8f568 --- /dev/null +++ b/sp-modules/matrix/config-paths-needed.json @@ -0,0 +1,15 @@ +[ + [ "selfprivacy", "domain" ], + [ "selfprivacy", "modules", "auth", "enable" ], + [ "services", "jitsi-meet", "enable" ], + [ "services", "jitsi-meet", "hostName" ], + [ "nixpkgs", "flake", "source" ], + [ "services", "postgresql", "package" ], + [ "selfprivacy", "modules", "matrix" ], + [ "selfprivacy", "passthru", "auth", "mkOAuth2ClientSecretFP" ], + [ "selfprivacy", "passthru", "auth", "mkServiceAccountTokenFP" ], + [ "selfprivacy", "passthru", "auth", "oauth2-discovery-url" ], + [ "selfprivacy", "passthru", "auth", "oauth2-provider-name" ], + [ "selfprivacy", "sso", "enable" ], + [ "selfprivacy", "useBinds" ] +] diff --git a/sp-modules/matrix/flake.nix b/sp-modules/matrix/flake.nix new file mode 100644 index 0000000..200b837 --- /dev/null +++ b/sp-modules/matrix/flake.nix @@ -0,0 +1,55 @@ +{ + description = "PoC SP module for Matrix service"; + + outputs = + { ... }: + { + nixosModules.default = import ./module.nix; + configPathsNeeded = builtins.fromJSON (builtins.readFile ./config-paths-needed.json); + meta = + { lib, ... }: + { + spModuleSchemaVersion = 1; + id = "matrix"; + name = "Matrix"; + description = "An open network for secure, decentralised communication"; + svgIcon = builtins.readFile ./icon.svg; + primarySubdomain = "elementSubdomain"; + isMovable = true; + isRequired = false; + backupDescription = "Messages, sessions, server signing keys and attachments"; + systemdServices = [ + "matrix-synapse.service" + "matrix-authentication-service.service" + "mas-kanidm-sync.service" + ]; + ownedFolders = [ + { + path = "/var/lib/matrix-synapse"; + owner = "matrix-synapse"; + group = "matrix-synapse"; + } + { + path = "/var/lib/matrix-authentication-service"; + owner = "matrix-authentication-service"; + group = "matrix-authentication-service"; + } + ]; + folders = []; + postgreDatabases = [ + "matrix-synapse" + "matrix-authentication-service" + ]; + license = [ + lib.licenses.agpl3Plus + ]; + homepage = "https://matrix.org"; + sourcePage = "https://github.com/element-hq"; + supportLevel = "experimental"; + sso = { + userGroup = "sp.matrix.users"; + adminGroup = "sp.matrix.admins"; + }; + }; + }; +} diff --git a/sp-modules/matrix/icon.svg b/sp-modules/matrix/icon.svg new file mode 100644 index 0000000..bb8278a --- /dev/null +++ b/sp-modules/matrix/icon.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/sp-modules/matrix/mas-kanidm-sync.py b/sp-modules/matrix/mas-kanidm-sync.py new file mode 100644 index 0000000..6d3a731 --- /dev/null +++ b/sp-modules/matrix/mas-kanidm-sync.py @@ -0,0 +1,119 @@ +import os +import time +import json +import requests +import psycopg + +from psycopg.rows import dict_row +from ulid import ULID + + +def read_file(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def getenv(name): + try: + return os.environ[name] + except KeyError: + print(f"Missing environment variable {name}. You should NOT run this script by hand, please use systemd unit mas-kanidm-sync.service.") + exit(1) + + +KANIDM_ULID = ULID.from_str(getenv("KANIDM_ULID")) +KANIDM_UUID = str(KANIDM_ULID.to_uuid()) +MAS_URL = getenv("MAS_URL") +KANIDM_URL = getenv("KANIDM_URL") +KANIDM_TOKEN = read_file(getenv("KANIDM_TOKEN_PATH")) +CLIENT_ID = getenv("CLIENT_ID") +CLIENT_SECRET = read_file(getenv("CLIENT_SECRET_PATH")) +MAS_POSTGRES_URL = getenv("MAS_POSTGRES_URL") + + +while True: + try: + r = requests.get(f"{MAS_URL}/.well-known/openid-configuration", timeout=3) + r.raise_for_status() + break + except Exception: + print( + f"MAS instance at {MAS_URL} is not responding, trying again in 3 seconds..." + ) + time.sleep(3) + +mas_access_token_req = requests.post( + f"{MAS_URL}/oauth2/token", + auth=(CLIENT_ID, CLIENT_SECRET), + data={"grant_type": "client_credentials", "scope": "urn:mas:admin"}, + timeout=5, +) +mas_access_token_req.raise_for_status() + +MAS_ACCESS_TOKEN = mas_access_token_req.json()["access_token"] + + +def sync_accounts(): + kanidm_persons_request = requests.get( + f"{KANIDM_URL}/v1/person", + headers={ + "Authorization": f"Bearer {KANIDM_TOKEN}", + "Content-Type": "application/json", + }, + timeout=5, + ) + kanidm_persons_request.raise_for_status() + kanidm_persons = kanidm_persons_request.json() + + with psycopg.connect( + MAS_POSTGRES_URL, autocommit=True, row_factory=dict_row + ) as pg_conn: + users = pg_conn.execute( + "SELECT users.user_id, upstream_oauth_links.subject, users.can_request_admin, users.username FROM users INNER JOIN upstream_oauth_links ON upstream_oauth_links.user_id = users.user_id WHERE upstream_oauth_links.upstream_oauth_provider_id = %s;", + (KANIDM_UUID,), + ).fetchall() + + for user_row in users: + found_in_kanidm = False + is_admin_in_kanidm = False + for kanidm_person in kanidm_persons: + if kanidm_person["attrs"]["uuid"][0] == user_row["subject"]: + found_in_kanidm = True + for group in kanidm_person["attrs"]["memberof"]: + if group.startswith("sp.matrix.admins@"): + is_admin_in_kanidm = True + username = user_row["username"] + if found_in_kanidm and user_row["can_request_admin"] != is_admin_in_kanidm: + mas_user_id = str(ULID.from_uuid(user_row["user_id"])) + print( + f"Updating user {username} ({mas_user_id}) can_request_admin field to {is_admin_in_kanidm}" + ) + response = requests.post( + f"{MAS_URL}/api/admin/v1/users/{mas_user_id}/set-admin", + data=json.dumps({"admin": is_admin_in_kanidm}), + headers={ + "Authorization": f"Bearer {MAS_ACCESS_TOKEN}", + "Content-Type": "application/json", + }, + timeout=5, + ) + if response.status_code != 200: + print( + f"ERROR: Failed to update can_request_admin field of user {username}" + ) + else: + print(f"Updated user {username} ({mas_user_id})") + elif not found_in_kanidm: + print( + "ERROR: User {username} is in MAS, but doesn't exist in Kanidm. TODO: should we deactivate it?" + ) + + +while True: + try: + sync_accounts() + except Exception as e: + print("Failed to sync MAS and Kanidm admin rights:") + print(e) + + time.sleep(30) diff --git a/sp-modules/matrix/module.nix b/sp-modules/matrix/module.nix new file mode 100644 index 0000000..4d384f1 --- /dev/null +++ b/sp-modules/matrix/module.nix @@ -0,0 +1,511 @@ +{ + config, + lib, + pkgs, + ... +}: +let + sp = config.selfprivacy; + cfg = sp.modules.matrix; + oauthClientID = "matrix"; + auth-passthru = config.selfprivacy.passthru.auth; + oauth2-provider-name = auth-passthru.oauth2-provider-name; + oauthDiscoveryURL = auth-passthru.oauth2-discovery-url oauthClientID; + + # TODO: Make background in Element configurable. This will require new option type in the app. + backgroundImage = builtins.fetchurl { + url = "https://git.selfprivacy.org/SelfPrivacy/imagery/raw/commit/44004ccc4467e22f5d6014bbf6b10b28fb8d511a/social/mastodon_instance_cover.png"; + sha256 = "sha256-em9+IkG1OIgH6+Pi00dtrw1yfFp85X6LNsP5y/r/T7o="; + }; + + # SelfPrivacy uses SP Module ID to identify the group! + adminsGroup = "sp.matrix.admins"; + usersGroup = "sp.matrix.users"; + + serviceAccountFP = auth-passthru.mkServiceAccountTokenFP "matrix-authentication-service"; + oauthClientSecretFP = auth-passthru.mkOAuth2ClientSecretFP "matrix-authentication-service"; + + synapseBase = "${cfg.subdomain}.${sp.domain}"; + synapseBaseURL = "https://" + synapseBase; + + clientConfig."m.homeserver" = { + base_url = synapseBaseURL; + server_name = sp.domain; + }; + serverConfig."m.server" = "${synapseBase}:443"; + mkWellKnown = data: '' + default_type application/json; + add_header Access-Control-Allow-Origin *; + add_header Strict-Transport-Security $hsts_header; + add_header 'Referrer-Policy' 'origin-when-cross-origin'; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + return 200 '${builtins.toJSON data}'; + ''; + + elementConfig = { + default_server_config = clientConfig; + disable_guests = true; + disable_login_language_selector = false; + default_country_code = "US"; + show_labs_settings = true; + default_theme = "light"; + + room_directory.servers = [ + "selfprivacy.org" + "matrix.org" + ]; + + jitsi.preffered_domain = lib.mkIf config.services.jitsi-meet.enable "https://${config.services.jitsi-meet.hostName}"; + + branding.welcome_background_url = "https://${cfg.elementSubdomain}.${sp.domain}/background.png"; + + setting_defaults = { + "UIFeature.feedback" = false; + "UIFeature.passwordReset" = false; + "UIFeature.deactivate" = false; + }; + }; + + yamlFormat = pkgs.formats.yaml { }; + + kanidmUlid = "01G65Z755AFWAKHE12NY0CQ9FH"; + synapseUlid = "0000000000000000000SYNAPSE"; + masKanidmSyncClientId = "01JZQSK4HXHXR2QATT40Y7497J"; + + masConfig = { + policy.data.admin_clients = lib.singleton masKanidmSyncClientId; + http = { + public_base = "https://${cfg.masSubdomain}.${sp.domain}"; + + listeners = [ + { + name = "web"; + resources = [ + { name = "discovery"; } + { name = "human"; } + { name = "oauth"; } + { name = "compat"; } + { name = "graphql"; } + { name = "adminapi"; } + { + name = "assets"; + path = "${pkgs.matrix-authentication-service}/share/matrix-authentication-service/assets"; + } + ]; + + binds = [ + { + host = "127.0.0.1"; + port = 8068; + } + ]; + proxy_protocol = false; + } + ]; + }; + database = { + uri = "postgresql:///matrix-authentication-service?host=/run/postgresql"; + }; + matrix = { + homeserver = sp.domain; + endpoint = "http://127.0.0.1:8078"; + }; + passwords = { + enabled = true; + schemes = [ + { + version = 1; + algorithm = "argon2id"; + } + ]; + }; + }; + + upstreamOauth2Template = pkgs.writeText "upstream_oauth2_template" ( + builtins.toJSON { + id = kanidmUlid; + issuer = lib.strings.removeSuffix "/.well-known/openid-configuration" oauthDiscoveryURL; + token_endpoint_auth_method = "client_secret_basic"; + human_name = oauth2-provider-name; + client_id = oauthClientID; + scope = "openid email profile"; + pkce_method = "always"; + + claims_imports.email.action = "force"; + } + ); + + experimentalMsc3861Template = pkgs.writeText "experimental_msc3861_template" ( + builtins.toJSON { + enabled = true; + issuer = "http://localhost:8068"; + client_id = synapseUlid; + client_auth_method = "client_secret_basic"; + } + ); + + masConfigFile = yamlFormat.generate "config.yaml" masConfig; + + masCliWithConfigs = "${pkgs.matrix-authentication-service}/bin/mas-cli ${ + lib.concatMapStringsSep " " (x: "--config ${x}") [ + masConfigFile + "${masDataDir}/secrets.yaml" + "${masDataDir}/oauth2_secrets.yaml" + ] + }"; + + masCliUserWrapper = pkgs.writeShellScriptBin "mas-cli" '' + su matrix-authentication-service -s /bin/sh -c "${masCliWithConfigs} $*" + ''; + + synapseDataDir = "/var/lib/matrix-synapse"; + masDataDir = "/var/lib/matrix-authentication-service"; + + # jitsi pollutes pkgs causing element-web to rebuild. + element-web = + (import config.nixpkgs.flake.source { + system = pkgs.system; + }).element-web.override + { + conf = elementConfig; + }; +in +{ + options.selfprivacy.modules.matrix = { + enable = + (lib.mkOption { + default = false; + type = lib.types.bool; + description = "Enable Matrix"; + }) + // { + meta = { + type = "enable"; + }; + }; + location = + (lib.mkOption { + type = lib.types.str; + description = "Matrix location"; + }) + // { + meta = { + type = "location"; + }; + }; + + subdomain = + (lib.mkOption { + default = "synapse"; + type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Matrix server subdomain"; + }) + // { + meta = { + widget = "subdomain"; + type = "string"; + regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + weight = 0; + }; + }; + + elementSubdomain = + (lib.mkOption { + default = "element"; + type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Element client subdomain"; + }) + // { + meta = { + widget = "subdomain"; + type = "string"; + regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + weight = 1; + }; + }; + + masSubdomain = + (lib.mkOption { + default = "mas"; + type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + description = "Matrix Authentication Service subdomain"; + }) + // { + meta = { + widget = "subdomain"; + type = "string"; + regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]"; + weight = 2; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.subdomain != cfg.elementSubdomain; + message = "Element should be hosted on separate subdomain"; + } + { + assertion = cfg.subdomain != cfg.masSubdomain; + message = "MAS should be hosted on separate subdomain"; + } + { + assertion = cfg.elementSubdomain != cfg.masSubdomain; + message = "MAS should be hosted on separate subdomain, not on Element domain"; + } + { + assertion = sp.sso.enable; + message = "Matrix cannot be enabled when SSO is disabled as Matrix uses OIDC for user authentication."; + } + ]; + + fileSystems = lib.mkIf sp.useBinds { + ${synapseDataDir} = { + device = "/volumes/${cfg.location}/matrix-synapse"; + options = [ "bind" ]; + }; + ${masDataDir} = { + device = "/volumes/${cfg.location}/matrix-authentication-service"; + options = [ "bind" ]; + }; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ "matrix-authentication-service" ]; + ensureUsers = [ + { + name = "matrix-synapse"; + ensureClauses.createdb = true; + } + { + name = "matrix-authentication-service"; + ensureDBOwnership = true; + } + ]; + }; + + services.matrix-synapse = { + enable = true; + extras = [ "oidc" ]; # add authlib to PYTHONPATH + settings.server_name = sp.domain; + settings.public_baseurl = synapseBaseURL; + settings.allow_guest_access = false; + settings.listeners = [ + { + port = 8078; + bind_addresses = [ "127.0.0.1" ]; + type = "http"; + tls = false; + x_forwarded = true; + resources = [ + { + names = [ + "client" + "federation" + ]; + compress = true; + } + ]; + } + ]; + + extraConfigFiles = [ + "${synapseDataDir}/mas_secrets.yaml" + ]; + }; + + services.nginx.virtualHosts.${sp.domain} = { + locations."= /.well-known/matrix/server".extraConfig = mkWellKnown serverConfig; + locations."= /.well-known/matrix/client".extraConfig = mkWellKnown clientConfig; + }; + + services.nginx.virtualHosts.${synapseBase} = { + useACMEHost = sp.domain; + forceSSL = true; + locations."/".proxyPass = "http://127.0.0.1:8078"; + locations."~ ^/_matrix/client/(.*)/(login|logout|refresh)".proxyPass = "http://127.0.0.1:8068"; + }; + + services.nginx.virtualHosts."${cfg.masSubdomain}.${sp.domain}" = { + useACMEHost = sp.domain; + forceSSL = true; + locations."/".proxyPass = "http://127.0.0.1:8068"; + }; + + services.nginx.virtualHosts."${cfg.elementSubdomain}.${sp.domain}" = { + useACMEHost = sp.domain; + forceSSL = true; + + root = element-web; + + locations."= /background.png" = { + extraConfig = '' + alias ${backgroundImage}; + ''; + }; + }; + + users.users.matrix-authentication-service = { + group = "matrix-authentication-service"; + isSystemUser = true; + }; + users.groups.matrix-authentication-service = { }; + + systemd = { + services.matrix-authentication-service = { + after = [ + "postgresql.service" + "kanidm.service" + ]; + wants = [ + "postgresql.service" + "kanidm.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + pkgs.matrix-authentication-service + pkgs.yq + ]; + serviceConfig = { + Slice = "matrix.slice"; + User = "matrix-authentication-service"; + Group = "matrix-authentication-service"; + ExecStartPre = [ + ( + "+" + + (pkgs.writeShellScript "matrix-authentication-service-prepare-secrets" '' + set -eu + + mkdir -p ${synapseDataDir} + mkdir -p ${masDataDir} + if [ ! -f ${masDataDir}/sync-client-secret ]; then + SYNC_CLIENT_SECRET=$(tr -dc A-Za-z0-9 ${masDataDir}/sync-client-secret + fi + if [ ! -f ${masDataDir}/matrix-token ]; then + MATRIX_TOKEN=$(tr -dc A-Za-z0-9 ${masDataDir}/matrix-token + fi + if [ ! -f ${masDataDir}/secrets.yaml ]; then + mas-cli config generate > ${masDataDir}/secrets.yaml + fi + cat ${masDataDir}/secrets.yaml | yq --arg matrixtoken "$(cat ${masDataDir}/matrix-token)" --arg syncclientsecret "$(cat ${masDataDir}/sync-client-secret)" --slurpfile template ${experimentalMsc3861Template} '{ + secrets: .secrets, + matrix: {secret: .matrix.secret}, + clients: [ + { client_id: "${synapseUlid}", client_auth_method: "client_secret_basic", client_secret: $matrixtoken }, + { client_id: "${masKanidmSyncClientId}", client_auth_method: "client_secret_basic", client_secret: $syncclientsecret } + ] + }' > ${masDataDir}/secrets.yaml + cat ${upstreamOauth2Template} | yq --rawfile clientsecret ${oauthClientSecretFP} '{upstream_oauth2: {providers: [ . * {client_secret: $clientsecret}]}}' > ${masDataDir}/oauth2_secrets.yaml + cat ${masDataDir}/secrets.yaml | yq --slurpfile template ${experimentalMsc3861Template} '{experimental_features: {msc3861: ($template[0] * {client_secret: .clients[0].client_secret, admin_token: .matrix.secret })}}' > ${synapseDataDir}/mas_secrets.yaml + chown matrix-authentication-service:matrix-authentication-service ${masDataDir} -R + chmod 640 ${masDataDir}/* + chown matrix-synapse:matrix-synapse ${synapseDataDir} + chown matrix-synapse:matrix-synapse ${synapseDataDir}/mas_secrets.yaml + chmod 640 ${synapseDataDir}/mas_secrets.yaml + '') + ) + ]; + ExecStart = '' + ${masCliWithConfigs} server + ''; + Restart = "on-failure"; + RestartSec = "1s"; + }; + }; + services.matrix-synapse-prepare-db = { + description = "Create Synapse database"; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + serviceConfig = { + Slice = "matrix.slice"; + User = "postgres"; + Group = "postgres"; + }; + script = '' + ${config.services.postgresql.package}/bin/psql -d postgres -f ${pkgs.writeText "create-synapse-db.sql" '' + CREATE DATABASE "matrix-synapse" WITH OWNER "matrix-synapse" + TEMPLATE template0 + LC_COLLATE = "C" + LC_CTYPE = "C"; + ''} || true + ''; + }; + services.mas-kanidm-sync = { + after = [ + "matrix-authentication-service.service" + "matrix-synapse-prepare-db.service" + ]; + requires = [ + "kanidm.service" + "matrix-authentication-service.service" + ]; + wantedBy = [ "multi-user.target" ]; + environment = { + PYTHONUNBUFFERED = "1"; + KANIDM_TOKEN_PATH = "%d/kanidm-token"; + MAS_URL = "https://${cfg.masSubdomain}.${sp.domain}"; + KANIDM_URL = "https://auth.${sp.domain}"; + KANIDM_ULID = kanidmUlid; + CLIENT_ID = masKanidmSyncClientId; + CLIENT_SECRET_PATH = "%d/client-secret"; + MAS_POSTGRES_URL = "postgresql:///matrix-authentication-service?host=/run/postgresql"; + }; + serviceConfig = { + Slice = "matrix.slice"; + User = "matrix-authentication-service"; + Group = "matrix-authentication-service"; + LoadCredential = ["kanidm-token:${serviceAccountFP}" "client-secret:${masDataDir}/sync-client-secret"]; + ExecStart = pkgs.writers.writePython3 "mas-kanidm-sync" { + doCheck = false; + libraries = with pkgs.python3Packages; [ + requests + psycopg + python-ulid + ]; + } (builtins.readFile ./mas-kanidm-sync.py); + }; + }; + services.matrix-synapse = { + after = [ + "matrix-authentication-service.service" + "matrix-synapse-prepare-db.service" + ]; + wants = [ + "matrix-authentication-service.service" + ]; + requires = [ + "matrix-synapse-prepare-db.service" + ]; + unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/matrix-synapse"; + serviceConfig.Slice = "matrix.slice"; + }; + slices.matrix = { + description = "Matrix server"; + }; + }; + + environment.systemPackages = [ + masCliUserWrapper + ]; + + selfprivacy.auth.clients.${oauthClientID} = { + inherit adminsGroup usersGroup; + subdomain = cfg.masSubdomain; + isTokenNeeded = true; + originLanding = "https://${cfg.masSubdomain}.${sp.domain}/"; + originUrl = "https://${cfg.masSubdomain}.${sp.domain}/upstream/callback/${kanidmUlid}"; + clientSystemdUnits = [ "matrix-authentication-service.service" ]; + enablePkce = true; + linuxUserOfClient = "matrix-authentication-service"; + linuxGroupOfClient = "matrix-authentication-service"; + }; + + services.kanidm.provision.systems.oauth2.${oauthClientID}.enableLegacyCrypto = true; + }; +}