diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index 1a86beaba73f754..20abba479a08125 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -91,6 +91,8 @@ In addition to numerous new and upgraded packages, this release has the followin - [Anki Sync Server](https://docs.ankiweb.net/sync-server.html), the official sync server built into recent versions of Anki. Available as [services.anki-sync-server](#opt-services.anki-sync-server.enable). The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been marked deprecated and will be dropped after 24.05 due to lack of maintenance of the anki-sync-server softwares. +- [mautrix-meta](https://github.com/mautrix/meta), a Matrix <-> Facebook and Matrix <-> Instagram hybrid puppeting/relaybot bridge. Available as services.mautrix-meta + - [transfer-sh](https://github.com/dutchcoders/transfer.sh), a tool that supports easy and fast file sharing from the command-line. Available as [services.transfer-sh](#opt-services.transfer-sh.enable). - [Suwayomi Server](https://github.com/Suwayomi/Suwayomi-Server), a free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org). Available as [services.suwayomi-server](#opt-services.suwayomi-server.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2ccaea466c6a756..e4977b527f57962 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -648,6 +648,7 @@ ./services/matrix/hebbot.nix ./services/matrix/maubot.nix ./services/matrix/mautrix-facebook.nix + ./services/matrix/mautrix-meta.nix ./services/matrix/mautrix-telegram.nix ./services/matrix/mautrix-whatsapp.nix ./services/matrix/mjolnir.nix diff --git a/nixos/modules/services/matrix/mautrix-meta.nix b/nixos/modules/services/matrix/mautrix-meta.nix new file mode 100644 index 000000000000000..b9cff033ce7165d --- /dev/null +++ b/nixos/modules/services/matrix/mautrix-meta.nix @@ -0,0 +1,491 @@ +{ config, pkgs, lib, ... }: + +let + settingsFormat = pkgs.formats.json {}; + + upperConfig = config; + cfg = config.services.mautrix-meta; + upperCfg = cfg; + + fullDataDir = cfg: "/var/lib/${cfg.dataDir}"; + + settingsFile = cfg: "${fullDataDir cfg}/config.json"; + settingsFileUnsubstituted = cfg: settingsFormat.generate "mautrix-meta-config.yaml" cfg.settings; + + metaName = name: "mautrix-meta-${name}"; + + enabledInstances = lib.filterAttrs (name: config: config.enable) config.services.mautrix-meta.instances; +in { + options = { + services.mautrix-meta = { + + package = lib.mkPackageOption pkgs "mautrix-meta" { }; + + instances = lib.mkOption rec { + type = lib.types.attrsOf (lib.types.submodule ({ config, name, ... }: let + in { + + options = { + + enable = lib.mkEnableOption "Mautrix-Meta, a Matrix <-> Facebook and Matrix <-> Instagram hybrid puppeting/relaybot bridge"; + + dataDir = lib.mkOption { + type = lib.types.str; + default = metaName name; + description = '' + Path to the directory with database, registration, and other data for the bridge service. + This path is relative to `/var/lib`, it cannot start with `../` (it cannot be outside of `/var/lib`). + ''; + }; + + registrationFile = lib.mkOption { + type = lib.types.path; + readOnly = true; + description = '' + Path to the yaml registration file of the appservice. + ''; + }; + + settings = lib.mkOption rec { + apply = lib.recursiveUpdate default; + inherit (settingsFormat) type; + default = { + homeserver = { + software = "standard"; + + domain = ""; + address = ""; + }; + + appservice = { + database = { + type = "sqlite3-fk-wal"; + uri = "file:${fullDataDir config}/mautrix-meta.db?_txlock=immediate"; + }; + + hostname = "localhost"; + port = 29319; + address = "http://${config.settings.appservice.hostname}:${toString config.settings.appservice.port}"; + }; + + meta = { + mode = ""; + }; + + bridge = { + # Enable encryption by default to make the bridge more secure + encryption = { + allow = true; + default = true; + require = true; + + # Recommended options from mautrix documentation + # for additional security. + delete_keys = { + dont_store_outbound = true; + ratchet_on_decrypt = true; + delete_fully_used_on_decrypt = true; + delete_prev_on_new_session = true; + delete_on_device_delete = true; + periodically_delete_expired = true; + delete_outdated_inbound = true; + }; + + verification_levels = { + receive = "cross-signed-tofu"; + send = "cross-signed-tofu"; + share = "cross-signed-tofu"; + }; + }; + + permissions = {}; + }; + + logging = { + min_level = "info"; + writers = lib.singleton { + type = "stdout"; + format = "pretty-colored"; + time_format = " "; + }; + }; + }; + defaultText = '' + { + homeserver = { + software = "standard"; + address = "https://''${config.settings.homeserver.domain}"; + }; + + appservice = { + database = { + type = "sqlite3-fk-wal"; + uri = "file:''${fullDataDir config}/mautrix-meta.db?_txlock=immediate"; + }; + + hostname = "localhost"; + port = 29319; + address = "http://''${config.settings.appservice.hostname}:''${toString config.settings.appservice.port}"; + }; + + bridge = { + # Require encryption by default to make the bridge more secure + encryption = { + allow = true; + default = true; + require = true; + + # Recommended options from mautrix documentation + # for optimal security. + delete_keys = { + dont_store_outbound = true; + ratchet_on_decrypt = true; + delete_fully_used_on_decrypt = true; + delete_prev_on_new_session = true; + delete_on_device_delete = true; + periodically_delete_expired = true; + delete_outdated_inbound = true; + }; + + verification_levels = { + receive = "cross-signed-tofu"; + send = "cross-signed-tofu"; + share = "cross-signed-tofu"; + }; + }; + }; + + logging = { + min_level = "info"; + writers = lib.singleton { + type = "stdout"; + format = "pretty-colored"; + time_format = " "; + }; + }; + }; + ''; + description = '' + {file}`config.json` configuration as a Nix attribute set. + Configuration options should match those described in + [example-config.yaml](https://github.com/mautrix/meta/blob/main/example-config.yaml). + + Secret tokens should be specified using {option}`environmentFile` + instead + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + File containing environment variables to substitute when copying the configuration + out of Nix store to the `services.mautrix-meta.dataDir`. + + Can be used for storing the secrets without making them available in the Nix store. + + For example, you can set `services.mautrix-meta.settings.appservice.as_token = "$MAUTRIX_META_APPSERVICE_AS_TOKEN"` + and then specify `MAUTRIX_META_APPSERVICE_AS_TOKEN="{token}"` in the environment file. + This value will get substituted into the configuration file as as token. + ''; + }; + + serviceDependencies = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = + [ config.registrationServiceUnit ] ++ + (lib.lists.optional upperConfig.services.matrix-synapse.enable upperConfig.services.matrix-synapse.serviceUnit) ++ + (lib.lists.optional upperConfig.services.matrix-conduit.enable "matrix-conduit.service") ++ + (lib.lists.optional upperConfig.services.dendrite.enable "dendrite.service"); + + defaultText = '' + [ config.registrationServiceUnit ] ++ + (lib.lists.optional upperConfig.services.matrix-synapse.enable upperConfig.services.matrix-synapse.serviceUnit) ++ + (lib.lists.optional upperConfig.services.matrix-conduit.enable "matrix-conduit.service") ++ + (lib.lists.optional upperConfig.services.dendrite.enable "dendrite.service"); + ''; + description = '' + List of Systemd services to require and wait for when starting the application service. + ''; + }; + + serviceUnit = lib.mkOption { + type = lib.types.str; + readOnly = true; + description = '' + The systemd unit (a service or a target) for other services to depend on if they + need to be started after matrix-synapse. + + This option is useful as the actual parent unit for all matrix-synapse processes + changes when configuring workers. + ''; + }; + + registrationServiceUnit = lib.mkOption { + type = lib.types.str; + readOnly = true; + description = '' + The registration service that generates the registration file. + + Systemd unit (a service or a target) for other services to depend on if they + need to be started after mautrix-meta registration service. + + This option is useful as the actual parent unit for all matrix-synapse processes + changes when configuring workers. + ''; + }; + }; + + config = { + serviceUnit = (metaName name) + ".service"; + registrationServiceUnit = (metaName name) + "-registration.service"; + registrationFile = (fullDataDir config) + "/meta-registration.yaml"; + }; + })); + + description = '' + Configuration of multiple `mautrix-meta` instances. + `services.mautrix-meta.instances.facebook` and `services.mautrix-meta.instances.instagram` + come preconfigured with meta.mode, appservice.id, bot username, display name and avatar. + ''; + + example = '' + { + facebook = { + enable = true; + settings = { + homeserver.domain = "example.com"; + }; + }; + + instagram = { + enable = true; + settings = { + homeserver.domain = "example.com"; + }; + }; + + messenger = { + enable = true; + settings = { + meta.mode = "messenger"; + homeserver.domain = "example.com"; + appservice = { + id = "messenger"; + bot = { + username = "messengerbot"; + displayname = "Messenger bridge bot"; + avatar = "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak"; + }; + }; + }; + }; + } + ''; + }; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf (enabledInstances != []) { + assertions = (lib.attrValues (lib.mapAttrs (name: cfg: { + assertion = cfg.settings.homeserver.domain != "" && cfg.settings.homeserver.address != ""; + message = '' + The options with information about the homeserver: + services.mautrix-meta.instances.${name}.settings.homeserver.domain and + services.mautrix-meta.instances.${name}.settings.homeserver.address have to be set. + ''; + }) enabledInstances)) ++ (lib.attrValues (lib.mapAttrs (name: cfg: { + assertion = builtins.elem cfg.settings.meta.mode [ "facebook" "facebook-tor" "messenger" "instagram" ]; + message = '' + The option services.mautrix-meta.instances.${name}.settings.meta.mode has to be set + to one of: facebook, facebook-tor, messenger, instagram. + This configures the mode of the bridge. + ''; + }) enabledInstances)) ++ (lib.attrValues (lib.mapAttrs (name: cfg: { + assertion = cfg.settings.bridge.permissions != {}; + message = '' + The option with permission for the bridge: services.mautrix-meta.instances.${name}.settings.bridge.permissions has to be set. + ''; + }) enabledInstances)); + + users.users = lib.mapAttrs' (name: cfg: lib.nameValuePair "mautrix-meta-${name}" { + isSystemUser = true; + group = "mautrix-meta"; + extraGroups = [ "mautrix-meta-registration" ]; + description = "Mautrix-Meta-${name} bridge user"; + }) enabledInstances; + + users.groups.mautrix-meta = {}; + users.groups.mautrix-meta-registration = lib.mkIf (config.services.matrix-synapse.enable) { + members = [ "matrix-synapse" ]; + }; + + systemd.services = lib.attrsets.concatMapAttrs (name: cfg: { + matrix-synapse = lib.mkIf (config.services.matrix-synapse.enable) { + wants = [ cfg.registrationServiceUnit ]; + after = [ cfg.registrationServiceUnit ]; + }; + + "${metaName name}-registration" = { + description = "Mautrix-Meta registration generation service - ${metaName name}"; + + script = '' + # substitute the settings file by environment variables + # in this case read from EnvironmentFile + rm -f '${settingsFile cfg}' + old_umask=$(umask) + umask 0177 + ${lib.getExe pkgs.envsubst} \ + -o '${settingsFile cfg}' \ + -i '${settingsFileUnsubstituted cfg}' + + umask 0127 + + has_tokens="$(${lib.getExe pkgs.yq} '.appservice | has("as_token") and has("hs_token")' '${settingsFileUnsubstituted cfg}')" + generated=0 + + if [[ (! -f '${cfg.registrationFile}') ]]; then + echo "Generating registration file" + ${lib.getExe upperCfg.package} \ + --generate-registration \ + --config='${settingsFile cfg}' \ + --registration='${cfg.registrationFile}' + generated=1 + chmod 640 '${cfg.registrationFile}' # doesn't generate with permissions based on umask + fi + + # Make sure correct tokens are in the registration file + if [[ "$has_tokens" == "true" ]]; then + echo "Substituting as_token, hs_token inside the registration file" + ${lib.getExe pkgs.yq} -s '.[1].as_token = .[0].appservice.as_token + | .[1].hs_token = .[0].appservice.hs_token + | .[1]' '${settingsFileUnsubstituted cfg}' '${cfg.registrationFile}' \ + > '${cfg.registrationFile}.tmp' + mv '${cfg.registrationFile}.tmp' '${cfg.registrationFile}' + fi + + umask 0177 + + # Generation overrides as_token, hs_token. + if [[ "$generated" == "1" || "$has_tokens" == "false" ]]; then + echo "Substituting as_token, hs_token inside the config file" + ${lib.getExe pkgs.yq} -s '.[0].appservice.as_token = .[1].as_token + | .[0].appservice.hs_token = .[1].hs_token + | .[0]' '${settingsFile cfg}' '${cfg.registrationFile}' \ + > '${settingsFile cfg}.tmp' + mv '${settingsFile cfg}.tmp' '${settingsFile cfg}' + fi + + umask $old_umask + + chown :mautrix-meta-registration '${cfg.registrationFile}' + ''; + + serviceConfig = { + UMask = 0027; + + User = "mautrix-meta-${name}"; + Group = "mautrix-meta"; + + SystemCallFilter = [ "@system-service" ]; + + ProtectSystem = "strict"; + ProtectHome = true; + + ReadWritePaths = fullDataDir cfg; + StateDirectory = cfg.dataDir; + EnvironmentFile = cfg.environmentFile; + }; + + restartTriggers = [ (settingsFileUnsubstituted cfg) ]; + }; + "${metaName name}" = { + description = "Mautrix-Meta bridge - ${metaName name}"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ] ++ cfg.serviceDependencies; + after = [ "network-online.target" ] ++ cfg.serviceDependencies; + + serviceConfig = { + Type = "simple"; + + User = "mautrix-meta-${name}"; + Group = "mautrix-meta"; + PrivateUsers = true; + + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + Restart = "on-failure"; + RestartSec = "30s"; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + SystemCallFilter = ["@system-service"]; + UMask = 0027; + + WorkingDirectory = fullDataDir cfg; + ReadWritePaths = fullDataDir cfg; + StateDirectory = cfg.dataDir; + EnvironmentFile = cfg.environmentFile; + + ExecStart = lib.escapeShellArgs [ + (lib.getExe upperCfg.package) + "--config=${settingsFile cfg}" + ]; + }; + restartTriggers = [ (settingsFileUnsubstituted cfg) ]; + }; + }) enabledInstances; + }) + { + services.mautrix-meta.instances = let + inherit (lib.modules) mkDefault; + in { + instagram = { + settings = { + meta.mode = "instagram"; + + appservice = { + id = mkDefault "instagram"; + port = mkDefault 29320; + bot = { + username = mkDefault "instagrambot"; + displayname = mkDefault "Instagram bridge bot"; + avatar = mkDefault "mxc://maunium.net/JxjlbZUlCPULEeHZSwleUXQv"; + }; + }; + }; + }; + facebook = { + settings = { + meta.mode = mkDefault "facebook"; + + appservice = { + id = mkDefault "facebook"; + port = mkDefault 29321; + bot = { + username = mkDefault "facebookbot"; + displayname = mkDefault "Facebook bridge bot"; + avatar = mkDefault "mxc://maunium.net/ygtkteZsXnGJLJHRchUwYWak"; + }; + }; + }; + }; + }; + } + ]; + + meta.maintainers = with lib.maintainers; [ rutherther ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 89e92bc8a9998cb..869b0e88a6db844 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -520,6 +520,8 @@ in { matrix-conduit = handleTest ./matrix/conduit.nix {}; matrix-synapse = handleTest ./matrix/synapse.nix {}; matrix-synapse-workers = handleTest ./matrix/synapse-workers.nix {}; + mautrix-meta-postgres = handleTest ./matrix/mautrix-meta-postgres.nix {}; + mautrix-meta-sqlite = handleTest ./matrix/mautrix-meta-sqlite.nix {}; mattermost = handleTest ./mattermost.nix {}; mealie = handleTest ./mealie.nix {}; mediamtx = handleTest ./mediamtx.nix {}; diff --git a/nixos/tests/matrix/mautrix-meta-postgres.nix b/nixos/tests/matrix/mautrix-meta-postgres.nix new file mode 100644 index 000000000000000..04af7234d8375a5 --- /dev/null +++ b/nixos/tests/matrix/mautrix-meta-postgres.nix @@ -0,0 +1,167 @@ +import ../make-test-python.nix ({ pkgs, ... }: + let + homeserverDomain = "server"; + homeserverUrl = "http://server:8008"; + userName = "alice"; + botUserName = "instagrambot"; + in + { + name = "mautrix-meta-postgres"; + meta.maintainers = pkgs.mautrix-meta.meta.maintainers; + meta.timeout = 300; + + nodes = { + server = { config, pkgs, ... }: { + services.postgresql = { + enable = true; + + ensureUsers = [ + { + name = "mautrix-meta-instagram"; + ensureDBOwnership = true; + } + ]; + + ensureDatabases = [ + "mautrix-meta-instagram" + ]; + }; + + systemd.services.mautrix-meta-instagram = { + wants = [ "postgres.service" ]; + after = [ "postgres.service" ]; + }; + + services.matrix-synapse = { + enable = true; + settings = { + database.name = "sqlite3"; + app_service_config_files = [ + config.services.mautrix-meta.instances.instagram.registrationFile + ]; + + enable_registration = true; + + # don't use this in production, always use some form of verification + enable_registration_without_verification = true; + + listeners = [ { + # The default but tls=false + bind_addresses = [ + "0.0.0.0" + ]; + port = 8008; + resources = [ { + "compress" = true; + "names" = [ "client" ]; + } { + "compress" = false; + "names" = [ "federation" ]; + } ]; + tls = false; + type = "http"; + } ]; + }; + }; + + services.mautrix-meta.instances.instagram = { + enable = true; + + settings = { + homeserver = { + address = homeserverUrl; + domain = homeserverDomain; + }; + + appservice = { + port = 8009; + + database = { + type = "postgres"; + uri = "postgres:///mautrix-meta-instagram?host=/var/run/postgresql"; + }; + + bot.username = botUserName; + }; + + bridge.permissions."@${userName}:server" = "user"; + }; + }; + + networking.firewall.allowedTCPPorts = [ 8008 8009 ]; + }; + + client = { pkgs, ... }: { + environment.systemPackages = [ + (pkgs.writers.writePython3Bin "do_test" + { + libraries = [ pkgs.python3Packages.matrix-nio ]; + flakeIgnore = [ + # We don't live in the dark ages anymore. + # Languages like Python that are whitespace heavy will overrun + # 79 characters.. + "E501" + ]; + } '' + import sys + import functools + import asyncio + + from nio import AsyncClient, RoomMessageNotice, RoomCreateResponse, RoomInviteResponse + + + async def message_callback(matrix: AsyncClient, msg: str, _r, e): + print("Received matrix text message: ", e) + assert msg in e.body + exit(0) # Success! + + + async def run(homeserver: str): + matrix = AsyncClient(homeserver) + response = await matrix.register("${userName}", "foobar") + print("Matrix register response: ", response) + + # Open a DM with the bridge bot + response = await matrix.room_create() + print("Matrix create room response:", response) + assert isinstance(response, RoomCreateResponse) + room_id = response.room_id + + response = await matrix.room_invite(room_id, "@${botUserName}:${homeserverDomain}") + assert isinstance(response, RoomInviteResponse) + + callback = functools.partial( + message_callback, matrix, "Hello, I'm an Instagram bridge bot." + ) + matrix.add_event_callback(callback, RoomMessageNotice) + + print("Waiting for matrix message...") + await matrix.sync_forever(timeout=30000) + + + if __name__ == "__main__": + asyncio.run(run(sys.argv[1])) + '' + ) + ]; + }; + }; + + testScript = '' + start_all() + + with subtest("start the server"): + # bridge + server.wait_for_unit("mautrix-meta-instagram.service") + + # homeserver + server.wait_for_unit("matrix-synapse.service") + + server.wait_for_open_port(8008) + # Bridge only opens the port after it contacts the homeserver + server.wait_for_open_port(8009) + + with subtest("ensure messages can be exchanged"): + client.succeed("do_test ${homeserverUrl} >&2") + ''; + }) diff --git a/nixos/tests/matrix/mautrix-meta-sqlite.nix b/nixos/tests/matrix/mautrix-meta-sqlite.nix new file mode 100644 index 000000000000000..4de607ae58b8000 --- /dev/null +++ b/nixos/tests/matrix/mautrix-meta-sqlite.nix @@ -0,0 +1,142 @@ +import ../make-test-python.nix ({ pkgs, ... }: + let + homeserverDomain = "server"; + homeserverUrl = "http://server:8008"; + userName = "alice"; + botUserName = "facebookbot"; + in + { + name = "mautrix-meta-sqlite"; + meta.maintainers = pkgs.mautrix-meta.meta.maintainers; + meta.timeout = 300; + + nodes = { + server = { config, pkgs, ... }: { + services.matrix-synapse = { + enable = true; + settings = { + database.name = "sqlite3"; + app_service_config_files = [ + config.services.mautrix-meta.instances.facebook.registrationFile + ]; + + enable_registration = true; + + # don't use this in production, always use some form of verification + enable_registration_without_verification = true; + + listeners = [ { + # The default but tls=false + bind_addresses = [ + "0.0.0.0" + ]; + port = 8008; + resources = [ { + "compress" = true; + "names" = [ "client" ]; + } { + "compress" = false; + "names" = [ "federation" ]; + } ]; + tls = false; + type = "http"; + } ]; + }; + }; + + services.mautrix-meta.instances.facebook = { + enable = true; + + settings = { + homeserver = { + address = homeserverUrl; + domain = homeserverDomain; + }; + + appservice = { + port = 8009; + + bot.username = botUserName; + }; + + bridge.permissions."@${userName}:server" = "user"; + }; + }; + + networking.firewall.allowedTCPPorts = [ 8008 8009 ]; + }; + + client = { pkgs, ... }: { + environment.systemPackages = [ + (pkgs.writers.writePython3Bin "do_test" + { + libraries = [ pkgs.python3Packages.matrix-nio ]; + flakeIgnore = [ + # We don't live in the dark ages anymore. + # Languages like Python that are whitespace heavy will overrun + # 79 characters.. + "E501" + ]; + } '' + import sys + import functools + import asyncio + + from nio import AsyncClient, RoomMessageNotice, RoomCreateResponse, RoomInviteResponse + + + async def message_callback(matrix: AsyncClient, msg: str, _r, e): + print("Received matrix text message: ", e) + assert msg in e.body + exit(0) # Success! + + + async def run(homeserver: str): + matrix = AsyncClient(homeserver) + response = await matrix.register("${userName}", "foobar") + print("Matrix register response: ", response) + + # Open a DM with the bridge bot + response = await matrix.room_create() + print("Matrix create room response:", response) + assert isinstance(response, RoomCreateResponse) + room_id = response.room_id + + response = await matrix.room_invite(room_id, "@${botUserName}:${homeserverDomain}") + assert isinstance(response, RoomInviteResponse) + + callback = functools.partial( + message_callback, matrix, "Hello, I'm an Instagram bridge bot." + ) + matrix.add_event_callback(callback, RoomMessageNotice) + + print("Waiting for matrix message...") + await matrix.sync_forever(timeout=30000) + + + if __name__ == "__main__": + asyncio.run(run(sys.argv[1])) + '' + ) + ]; + }; + }; + + testScript = '' + start_all() + + with subtest("start the server"): + # bridge + server.wait_for_unit("mautrix-meta-facebook.service") + + # homeserver + server.wait_for_unit("matrix-synapse.service") + + server.wait_for_open_port(8008) + # Bridge only opens the port after it contacts the homeserver + server.wait_for_open_port(8009) + + with subtest("ensure messages can be exchanged"): + client.succeed("do_test ${homeserverUrl} >&2") + ''; + })