about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--nixos/doc/manual/release-notes/rl-2405.section.md2
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/services/matrix/mautrix-meta.nix562
-rw-r--r--nixos/tests/all-tests.nix2
-rw-r--r--nixos/tests/matrix/mautrix-meta-postgres.nix221
-rw-r--r--nixos/tests/matrix/mautrix-meta-sqlite.nix247
6 files changed, 1035 insertions, 0 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md
index 1a86beaba73f7..20abba479a081 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 2ccaea466c6a7..e4977b527f579 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 0000000000000..b8a5cdc72065b
--- /dev/null
+++ b/nixos/modules/services/matrix/mautrix-meta.nix
@@ -0,0 +1,562 @@
+{ config, pkgs, lib, ... }:
+
+let
+  settingsFormat = pkgs.formats.yaml {};
+
+  upperConfig = config;
+  cfg = config.services.mautrix-meta;
+  upperCfg = cfg;
+
+  fullDataDir = cfg: "/var/lib/${cfg.dataDir}";
+
+  settingsFile = cfg: "${fullDataDir cfg}/config.yaml";
+  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;
+  registerToSynapseInstances = lib.filterAttrs (name: config: config.enable && config.registerToSynapse) config.services.mautrix-meta.instances;
+in {
+  options = {
+    services.mautrix-meta = {
+
+      package = lib.mkPackageOption pkgs "mautrix-meta" { };
+
+      instances = lib.mkOption {
+        type = lib.types.attrsOf (lib.types.submodule ({ config, name, ... }: {
+
+          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.
+              '';
+            };
+
+            registerToSynapse = lib.mkOption {
+              type = lib.types.bool;
+              default = true;
+              description = ''
+                Whether to add registration file to `services.matrix-synapse.settings.app_service_config_files` and
+                make Synapse wait for registration service.
+              '';
+            };
+
+            settings = lib.mkOption rec {
+              apply = lib.recursiveUpdate default;
+              inherit (settingsFormat) type;
+              default = {
+                homeserver = {
+                  software = "standard";
+
+                  domain = "";
+                  address = "";
+                };
+
+                appservice = {
+                  id = "";
+
+                  database = {
+                    type = "sqlite3-fk-wal";
+                    uri = "file:${fullDataDir config}/mautrix-meta.db?_txlock=immediate";
+                  };
+
+                  bot = {
+                    username = "";
+                  };
+
+                  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.yaml` 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.mkMerge (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.
+          '';
+        }
+        {
+          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.
+          '';
+        }
+        {
+          assertion = cfg.settings.bridge.permissions != {};
+          message = ''
+            The option `services.mautrix-meta.instances.${name}.settings.bridge.permissions` has to be set.
+          '';
+        }
+        {
+          assertion = cfg.settings.appservice.id != "";
+          message = ''
+            The option `services.mautrix-meta.instances.${name}.settings.appservice.id` has to be set.
+          '';
+        }
+        {
+          assertion = cfg.settings.appservice.bot.username != "";
+          message = ''
+            The option `services.mautrix-meta.instances.${name}.settings.appservice.bot.username` 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 = {
+        members = lib.lists.optional config.services.matrix-synapse.enable "matrix-synapse";
+      };
+
+      services.matrix-synapse = lib.mkIf (config.services.matrix-synapse.enable) (let
+        registrationFiles = lib.attrValues
+          (lib.mapAttrs (name: cfg: cfg.registrationFile) registerToSynapseInstances);
+      in {
+        settings.app_service_config_files = registrationFiles;
+      });
+
+      systemd.services = lib.mkMerge [
+        {
+          matrix-synapse = lib.mkIf (config.services.matrix-synapse.enable) (let
+            registrationServices = lib.attrValues
+              (lib.mapAttrs (name: cfg: cfg.registrationServiceUnit) registerToSynapseInstances);
+          in {
+            wants = registrationServices;
+            after = registrationServices;
+          });
+        }
+
+        (lib.mapAttrs' (name: cfg: lib.nameValuePair "${metaName name}-registration" {
+          description = "Mautrix-Meta registration generation service - ${metaName name}";
+
+          path = [
+            pkgs.yq
+            pkgs.envsubst
+            upperCfg.package
+          ];
+
+          script = ''
+            # substitute the settings file by environment variables
+            # in this case read from EnvironmentFile
+            rm -f '${settingsFile cfg}'
+            old_umask=$(umask)
+            umask 0177
+            envsubst \
+              -o '${settingsFile cfg}' \
+              -i '${settingsFileUnsubstituted cfg}'
+
+            config_has_tokens=$(yq '.appservice | has("as_token") and has("hs_token")' '${settingsFile cfg}')
+            registration_already_exists=$([[ -f '${cfg.registrationFile}' ]] && echo "true" || echo "false")
+
+            echo "There are tokens in the config: $config_has_tokens"
+            echo "Registration already existed: $registration_already_exists"
+
+            # tokens not configured from config/environment file, and registration file
+            # is already generated, override tokens in config to make sure they are not lost
+            if [[ $config_has_tokens == "false" && $registration_already_exists == "true" ]]; then
+              echo "Copying as_token, hs_token from registration into configuration"
+              yq -sY '.[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
+
+            # make sure --generate-registration does not affect config.yaml
+            cp '${settingsFile cfg}' '${settingsFile cfg}.tmp'
+
+            echo "Generating registration file"
+            mautrix-meta \
+              --generate-registration \
+              --config='${settingsFile cfg}.tmp' \
+              --registration='${cfg.registrationFile}'
+
+            rm '${settingsFile cfg}.tmp'
+
+            # no tokens configured, and new were just generated by generate registration for first time
+            if [[ $config_has_tokens == "false" && $registration_already_exists == "false" ]]; then
+              echo "Copying newly generated as_token, hs_token from registration into configuration"
+              yq -sY '.[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
+
+            # Make sure correct tokens are in the registration file
+            if [[ $config_has_tokens == "true" || $registration_already_exists == "true" ]]; then
+              echo "Copying as_token, hs_token from configuration to the registration file"
+              yq -sY '.[1].as_token = .[0].appservice.as_token
+                | .[1].hs_token = .[0].appservice.hs_token
+                | .[1]' '${settingsFile cfg}' '${cfg.registrationFile}' \
+                > '${cfg.registrationFile}.tmp'
+              mv '${cfg.registrationFile}.tmp' '${cfg.registrationFile}'
+            fi
+
+            umask $old_umask
+
+            chown :mautrix-meta-registration '${cfg.registrationFile}'
+            chmod 640 '${cfg.registrationFile}'
+          '';
+
+          serviceConfig = {
+            Type = "oneshot";
+            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) ];
+        }) enabledInstances)
+
+        (lib.mapAttrs' (name: cfg: lib.nameValuePair "${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 = mkDefault "instagram";
+
+            bridge = {
+              username_template = mkDefault "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";
+
+            bridge = {
+              username_template = 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 89e92bc8a9998..869b0e88a6db8 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 0000000000000..c9a45788afaf6
--- /dev/null
+++ b/nixos/tests/matrix/mautrix-meta-postgres.nix
@@ -0,0 +1,221 @@
+import ../make-test-python.nix ({ pkgs, ... }:
+  let
+    homeserverDomain = "server";
+    homeserverUrl = "http://server:8008";
+    userName = "alice";
+    botUserName = "instagrambot";
+
+    asToken = "this-is-my-totally-randomly-generated-as-token";
+    hsToken = "this-is-my-totally-randomly-generated-hs-token";
+  in
+  {
+    name = "mautrix-meta-postgres";
+    meta.maintainers = pkgs.mautrix-meta.meta.maintainers;
+
+    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";
+
+            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;
+
+          environmentFile = pkgs.writeText ''my-secrets'' ''
+            AS_TOKEN=${asToken}
+            HS_TOKEN=${hsToken}
+          '';
+
+          settings = {
+            homeserver = {
+              address = homeserverUrl;
+              domain = homeserverDomain;
+            };
+
+            appservice = {
+              port = 8009;
+
+              as_token = "$AS_TOKEN";
+              hs_token = "$HS_TOKEN";
+
+              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 = ''
+      def extract_token(data):
+          stdout = data[1]
+          stdout = stdout.strip()
+          line = stdout.split('\n')[-1]
+          return line.split(':')[-1].strip("\" '\n")
+
+      def get_token_from(token, file):
+          data = server.execute(f"cat {file} | grep {token}")
+          return extract_token(data)
+
+      def get_as_token_from(file):
+          return get_token_from("as_token", file)
+
+      def get_hs_token_from(file):
+          return get_token_from("hs_token", file)
+
+      config_yaml = "/var/lib/mautrix-meta-instagram/config.yaml"
+      registration_yaml = "/var/lib/mautrix-meta-instagram/meta-registration.yaml"
+
+      expected_as_token = "${asToken}"
+      expected_hs_token = "${hsToken}"
+
+      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")
+
+      with subtest("ensure as_token, hs_token match from environment file"):
+          as_token = get_as_token_from(config_yaml)
+          hs_token = get_hs_token_from(config_yaml)
+          as_token_registration = get_as_token_from(registration_yaml)
+          hs_token_registration = get_hs_token_from(registration_yaml)
+
+          assert as_token == expected_as_token, f"as_token in config should match the one specified (is: {as_token}, expected: {expected_as_token})"
+          assert hs_token == expected_hs_token, f"hs_token in config should match the one specified (is: {hs_token}, expected: {expected_hs_token})"
+          assert as_token_registration == expected_as_token, f"as_token in registration should match the one specified (is: {as_token_registration}, expected: {expected_as_token})"
+          assert hs_token_registration == expected_hs_token, f"hs_token in registration should match the one specified (is: {hs_token_registration}, expected: {expected_hs_token})"
+
+      with subtest("ensure as_token and hs_token stays same after restart"):
+          server.systemctl("restart mautrix-meta-instagram")
+          server.wait_for_open_port(8009)
+
+          as_token = get_as_token_from(config_yaml)
+          hs_token = get_hs_token_from(config_yaml)
+          as_token_registration = get_as_token_from(registration_yaml)
+          hs_token_registration = get_hs_token_from(registration_yaml)
+
+          assert as_token == expected_as_token, f"as_token in config should match the one specified (is: {as_token}, expected: {expected_as_token})"
+          assert hs_token == expected_hs_token, f"hs_token in config should match the one specified (is: {hs_token}, expected: {expected_hs_token})"
+          assert as_token_registration == expected_as_token, f"as_token in registration should match the one specified (is: {as_token_registration}, expected: {expected_as_token})"
+          assert hs_token_registration == expected_hs_token, f"hs_token in registration should match the one specified (is: {hs_token_registration}, expected: {expected_hs_token})"
+    '';
+  })
diff --git a/nixos/tests/matrix/mautrix-meta-sqlite.nix b/nixos/tests/matrix/mautrix-meta-sqlite.nix
new file mode 100644
index 0000000000000..b5e580620049a
--- /dev/null
+++ b/nixos/tests/matrix/mautrix-meta-sqlite.nix
@@ -0,0 +1,247 @@
+import ../make-test-python.nix ({ pkgs, ... }:
+  let
+    homeserverDomain = "server";
+    homeserverUrl = "http://server:8008";
+    username = "alice";
+    instagramBotUsername = "instagrambot";
+    facebookBotUsername = "facebookbot";
+  in
+  {
+    name = "mautrix-meta-sqlite";
+    meta.maintainers = pkgs.mautrix-meta.meta.maintainers;
+
+    nodes = {
+      server = { config, pkgs, ... }: {
+        services.matrix-synapse = {
+          enable = true;
+          settings = {
+            database.name = "sqlite3";
+
+            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 = facebookBotUsername;
+            };
+
+            bridge.permissions."@${username}:server" = "user";
+          };
+        };
+
+        services.mautrix-meta.instances.instagram = {
+          enable = true;
+
+          settings = {
+            homeserver = {
+              address = homeserverUrl;
+              domain = homeserverDomain;
+            };
+
+            appservice = {
+              port = 8010;
+
+              bot.username = instagramBotUsername;
+            };
+
+            bridge.permissions."@${username}:server" = "user";
+          };
+        };
+
+        networking.firewall.allowedTCPPorts = [ 8008 ];
+      };
+
+      client = { pkgs, ... }: {
+        environment.systemPackages = [
+          (pkgs.writers.writePython3Bin "register_user"
+          {
+            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 asyncio
+
+              from nio import AsyncClient
+
+
+              async def run(username: str, homeserver: str):
+                  matrix = AsyncClient(homeserver)
+
+                  response = await matrix.register(username, "foobar")
+                  print("Matrix register response: ", response)
+
+
+              if __name__ == "__main__":
+                  asyncio.run(run(sys.argv[1], sys.argv[2]))
+            ''
+          )
+          (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(username: str, bot_username: str, homeserver: str):
+                  matrix = AsyncClient(homeserver, f"@{username}:${homeserverDomain}")
+
+                  response = await matrix.login("foobar")
+                  print("Matrix login 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, f"@{bot_username}:${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], sys.argv[2], sys.argv[3]))
+            ''
+          )
+        ];
+      };
+    };
+
+    testScript = ''
+      def extract_token(data):
+          stdout = data[1]
+          stdout = stdout.strip()
+          line = stdout.split('\n')[-1]
+          return line.split(':')[-1].strip("\" '\n")
+
+      def get_token_from(token, file):
+          data = server.execute(f"cat {file} | grep {token}")
+          return extract_token(data)
+
+      def get_as_token_from(file):
+          return get_token_from("as_token", file)
+
+      def get_hs_token_from(file):
+          return get_token_from("hs_token", file)
+
+      config_yaml = "/var/lib/mautrix-meta-facebook/config.yaml"
+      registration_yaml = "/var/lib/mautrix-meta-facebook/meta-registration.yaml"
+
+      start_all()
+
+      with subtest("wait for bridges and homeserver"):
+          # bridge
+          server.wait_for_unit("mautrix-meta-facebook.service")
+          server.wait_for_unit("mautrix-meta-instagram.service")
+
+          # homeserver
+          server.wait_for_unit("matrix-synapse.service")
+
+          server.wait_for_open_port(8008)
+          # Bridges only open the port after they contact the homeserver
+          server.wait_for_open_port(8009)
+          server.wait_for_open_port(8010)
+
+      with subtest("register user"):
+          client.succeed("register_user ${username} ${homeserverUrl} >&2")
+
+      with subtest("ensure messages can be exchanged"):
+          client.succeed("do_test ${username} ${facebookBotUsername} ${homeserverUrl} >&2")
+          client.succeed("do_test ${username} ${instagramBotUsername} ${homeserverUrl} >&2")
+
+      with subtest("ensure as_token and hs_token stays same after restart"):
+          generated_as_token_facebook = get_as_token_from(config_yaml)
+          generated_hs_token_facebook = get_hs_token_from(config_yaml)
+
+          generated_as_token_facebook_registration = get_as_token_from(registration_yaml)
+          generated_hs_token_facebook_registration = get_hs_token_from(registration_yaml)
+
+          # Indirectly checks the as token is not set to something like empty string or "null"
+          assert len(generated_as_token_facebook) > 20, f"as_token ({generated_as_token_facebook}) is too short, something went wrong"
+          assert len(generated_hs_token_facebook) > 20, f"hs_token ({generated_hs_token_facebook}) is too short, something went wrong"
+
+          assert generated_as_token_facebook == generated_as_token_facebook_registration, f"as_token should be the same in registration ({generated_as_token_facebook_registration}) and configuration ({generated_as_token_facebook}) files"
+          assert generated_hs_token_facebook == generated_hs_token_facebook_registration, f"hs_token should be the same in registration ({generated_hs_token_facebook_registration}) and configuration ({generated_hs_token_facebook}) files"
+
+          server.systemctl("restart mautrix-meta-facebook")
+          server.systemctl("restart mautrix-meta-instagram")
+
+          server.wait_for_open_port(8009)
+          server.wait_for_open_port(8010)
+
+          new_as_token_facebook = get_as_token_from(config_yaml)
+          new_hs_token_facebook = get_hs_token_from(config_yaml)
+
+          assert generated_as_token_facebook == new_as_token_facebook, f"as_token should stay the same after restart inside the configuration file (is: {new_as_token_facebook}, was: {generated_as_token_facebook})"
+          assert generated_hs_token_facebook == new_hs_token_facebook, f"hs_token should stay the same after restart inside the configuration file (is: {new_hs_token_facebook}, was: {generated_hs_token_facebook})"
+
+          new_as_token_facebook = get_as_token_from(registration_yaml)
+          new_hs_token_facebook = get_hs_token_from(registration_yaml)
+
+          assert generated_as_token_facebook == new_as_token_facebook, f"as_token should stay the same after restart inside the registration file (is: {new_as_token_facebook}, was: {generated_as_token_facebook})"
+          assert generated_hs_token_facebook == new_hs_token_facebook, f"hs_token should stay the same after restart inside the registration file (is: {new_hs_token_facebook}, was: {generated_hs_token_facebook})"
+
+      with subtest("ensure messages can be exchanged after restart"):
+          client.succeed("do_test ${username} ${instagramBotUsername} ${homeserverUrl} >&2")
+          client.succeed("do_test ${username} ${facebookBotUsername} ${homeserverUrl} >&2")
+    '';
+  })