diff --git a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
index bfabda4cb4da..54f0b0bf0fc2 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
@@ -75,6 +75,13 @@
services.filebeat.
+
+
+ heisenbridge,
+ a bouncer-style Matrix IRC bridge. Available as
+ services.heisenbridge.
+
+
PowerDNS-Admin,
diff --git a/nixos/doc/manual/release-notes/rl-2205.section.md b/nixos/doc/manual/release-notes/rl-2205.section.md
index 05f1a26a0b6d..81bac061572d 100644
--- a/nixos/doc/manual/release-notes/rl-2205.section.md
+++ b/nixos/doc/manual/release-notes/rl-2205.section.md
@@ -25,6 +25,8 @@ In addition to numerous new and upgraded packages, this release has the followin
- [filebeat](https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-overview.html), a lightweight shipper for forwarding and centralizing log data. Available as [services.filebeat](#opt-services.filebeat.enable).
+- [heisenbridge](https://github.com/hifi/heisenbridge), a bouncer-style Matrix IRC bridge. Available as [services.heisenbridge](options.html#opt-services.heisenbridge.enable).
+
- [PowerDNS-Admin](https://github.com/ngoduykhanh/PowerDNS-Admin), a web interface for the PowerDNS server. Available at [services.powerdns-admin](options.html#opt-services.powerdns-admin.enable).
- [maddy](https://maddy.email), a composable all-in-one mail server. Available as [services.maddy](options.html#opt-services.maddy.enable).
diff --git a/nixos/modules/services/misc/heisenbridge.nix b/nixos/modules/services/misc/heisenbridge.nix
new file mode 100644
index 000000000000..c008c4b3999e
--- /dev/null
+++ b/nixos/modules/services/misc/heisenbridge.nix
@@ -0,0 +1,208 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+ cfg = config.services.heisenbridge;
+
+ pkg = config.services.heisenbridge.package;
+ bin = "${pkg}/bin/heisenbridge";
+
+ jsonType = (pkgs.formats.json { }).type;
+
+ registrationFile = "/var/lib/heisenbridge/registration.yml";
+ # JSON is a proper subset of YAML
+ bridgeConfig = builtins.toFile "heisenbridge-registration.yml" (builtins.toJSON {
+ id = "heisenbridge";
+ url = cfg.registrationUrl;
+ # Don't specify as_token and hs_token
+ rate_limited = false;
+ sender_localpart = "heisenbridge";
+ namespaces = cfg.namespaces;
+ });
+in
+{
+ options.services.heisenbridge = {
+ enable = mkEnableOption "the Matrix<->IRC bridge";
+
+ package = mkOption {
+ type = types.package;
+ default = pkgs.heisenbridge;
+ defaultText = "pkgs.heisenbridge";
+ example = "pkgs.heisenbridge.override { … = …; }";
+ description = ''
+ Package of the application to run, exposed for overriding purposes.
+ '';
+ };
+
+ homeserver = mkOption {
+ type = types.str;
+ description = "The URL to the home server for client-server API calls";
+ example = "http://localhost:8008";
+ };
+
+ registrationUrl = mkOption {
+ type = types.str;
+ description = ''
+ The URL where the application service is listening for HS requests, from the Matrix HS perspective.#
+ The default value assumes the bridge runs on the same host as the home server, in the same network.
+ '';
+ example = "https://matrix.example.org";
+ default = "http://${cfg.address}:${toString cfg.port}";
+ defaultText = "http://$${cfg.address}:$${toString cfg.port}";
+ };
+
+ address = mkOption {
+ type = types.str;
+ description = "Address to listen on. IPv6 does not seem to be supported.";
+ default = "127.0.0.1";
+ example = "0.0.0.0";
+ };
+
+ port = mkOption {
+ type = types.port;
+ description = "The port to listen on";
+ default = 9898;
+ };
+
+ debug = mkOption {
+ type = types.bool;
+ description = "More verbose logging. Recommended during initial setup.";
+ default = false;
+ };
+
+ owner = mkOption {
+ type = types.nullOr types.str;
+ description = ''
+ Set owner MXID otherwise first talking local user will claim the bridge
+ '';
+ default = null;
+ example = "@admin:example.org";
+ };
+
+ namespaces = mkOption {
+ description = "Configure the 'namespaces' section of the registration.yml for the bridge and the server";
+ # TODO link to Matrix documentation of the format
+ type = types.submodule {
+ freeformType = jsonType;
+ };
+
+ default = {
+ users = [
+ {
+ regex = "@irc_.*";
+ exclusive = true;
+ }
+ ];
+ aliases = [ ];
+ rooms = [ ];
+ };
+ };
+
+ identd.enable = mkEnableOption "identd service support";
+ identd.port = mkOption {
+ type = types.port;
+ description = "identd listen port";
+ default = 113;
+ };
+
+ extraArgs = mkOption {
+ type = types.listOf types.str;
+ description = "Heisenbridge is configured over the command line. Append extra arguments here";
+ default = [ ];
+ };
+ };
+
+ config = mkIf cfg.enable {
+ systemd.services.heisenbridge = {
+ description = "Matrix<->IRC bridge";
+ before = [ "matrix-synapse.service" ]; # So the registration file can be used by Synapse
+ wantedBy = [ "multi-user.target" ];
+
+ preStart = ''
+ umask 077
+ set -e -u -o pipefail
+
+ if ! [ -f "${registrationFile}" ]; then
+ # Generate registration file if not present (actually, we only care about the tokens in it)
+ ${bin} --generate --config ${registrationFile}
+ fi
+
+ # Overwrite the registration file with our generated one (the config may have changed since then),
+ # but keep the tokens. Two step procedure to be failure safe
+ ${pkgs.yq}/bin/yq --slurp \
+ '.[0] + (.[1] | {as_token, hs_token})' \
+ ${bridgeConfig} \
+ ${registrationFile} \
+ > ${registrationFile}.new
+ mv -f ${registrationFile}.new ${registrationFile}
+
+ # Grant Synapse access to the registration
+ if ${getBin pkgs.glibc}/bin/getent group matrix-synapse > /dev/null; then
+ chgrp -v matrix-synapse ${registrationFile}
+ chmod -v g+r ${registrationFile}
+ fi
+ '';
+
+ serviceConfig = rec {
+ Type = "simple";
+ ExecStart = lib.concatStringsSep " " (
+ [
+ bin
+ (if cfg.debug then "-vvv" else "-v")
+ "--config"
+ registrationFile
+ "--listen-address"
+ (lib.escapeShellArg cfg.address)
+ "--listen-port"
+ (toString cfg.port)
+ ]
+ ++ (lib.optionals (cfg.owner != null) [
+ "--owner"
+ (lib.escapeShellArg cfg.owner)
+ ])
+ ++ (lib.optionals cfg.identd.enable [
+ "--identd"
+ "--identd-port"
+ (toString cfg.identd.port)
+ ])
+ ++ [
+ (lib.escapeShellArg cfg.homeserver)
+ ]
+ ++ (map (lib.escapeShellArg) cfg.extraArgs)
+ );
+
+ ProtectHome = true;
+ PrivateDevices = true;
+ ProtectKernelTunables = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ StateDirectory = "heisenbridge";
+ StateDirectoryMode = "755";
+
+ User = "heisenbridge";
+ Group = "heisenbridge";
+
+ CapabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024 || cfg.identd.port < 1024) "CAP_NET_BIND_SERVICE";
+ AmbientCapabilities = CapabilityBoundingSet;
+ NoNewPrivileges = true;
+
+ LockPersonality = true;
+ RestrictRealtime = true;
+ PrivateMounts = true;
+ SystemCallFilter = "~@aio @clock @cpu-emulation @debug @keyring @memlock @module @mount @obsolete @raw-io @setuid @swap";
+ SystemCallArchitectures = "native";
+ RestrictAddressFamilies = "AF_INET AF_INET6";
+ };
+ };
+
+ users.groups.heisenbridge = {};
+ users.users.heisenbridge = {
+ description = "Service user for the Heisenbridge";
+ group = "heisenbridge";
+ isSystemUser = true;
+ };
+ };
+
+ meta.maintainers = [ lib.maintainers.piegames ];
+}