1{ config, pkgs, lib, ... }:
2
3with lib;
4
5let
6 cfg = config.services.heisenbridge;
7
8 pkg = config.services.heisenbridge.package;
9 bin = "${pkg}/bin/heisenbridge";
10
11 jsonType = (pkgs.formats.json { }).type;
12
13 registrationFile = "/var/lib/heisenbridge/registration.yml";
14 # JSON is a proper subset of YAML
15 bridgeConfig = builtins.toFile "heisenbridge-registration.yml" (builtins.toJSON {
16 id = "heisenbridge";
17 url = cfg.registrationUrl;
18 # Don't specify as_token and hs_token
19 rate_limited = false;
20 sender_localpart = "heisenbridge";
21 namespaces = cfg.namespaces;
22 });
23in
24{
25 options.services.heisenbridge = {
26 enable = mkEnableOption "the Matrix to IRC bridge";
27
28 package = mkPackageOption pkgs "heisenbridge" { };
29
30 homeserver = mkOption {
31 type = types.str;
32 description = "The URL to the home server for client-server API calls";
33 example = "http://localhost:8008";
34 };
35
36 registrationUrl = mkOption {
37 type = types.str;
38 description = ''
39 The URL where the application service is listening for HS requests, from the Matrix HS perspective.#
40 The default value assumes the bridge runs on the same host as the home server, in the same network.
41 '';
42 example = "https://matrix.example.org";
43 default = "http://${cfg.address}:${toString cfg.port}";
44 defaultText = "http://$${cfg.address}:$${toString cfg.port}";
45 };
46
47 address = mkOption {
48 type = types.str;
49 description = "Address to listen on. IPv6 does not seem to be supported.";
50 default = "127.0.0.1";
51 example = "0.0.0.0";
52 };
53
54 port = mkOption {
55 type = types.port;
56 description = "The port to listen on";
57 default = 9898;
58 };
59
60 debug = mkOption {
61 type = types.bool;
62 description = "More verbose logging. Recommended during initial setup.";
63 default = false;
64 };
65
66 owner = mkOption {
67 type = types.nullOr types.str;
68 description = ''
69 Set owner MXID otherwise first talking local user will claim the bridge
70 '';
71 default = null;
72 example = "@admin:example.org";
73 };
74
75 namespaces = mkOption {
76 description = "Configure the 'namespaces' section of the registration.yml for the bridge and the server";
77 # TODO link to Matrix documentation of the format
78 type = types.submodule {
79 freeformType = jsonType;
80 };
81
82 default = {
83 users = [
84 {
85 regex = "@irc_.*";
86 exclusive = true;
87 }
88 ];
89 aliases = [ ];
90 rooms = [ ];
91 };
92 };
93
94 identd.enable = mkEnableOption "identd service support";
95 identd.port = mkOption {
96 type = types.port;
97 description = "identd listen port";
98 default = 113;
99 };
100
101 extraArgs = mkOption {
102 type = types.listOf types.str;
103 description = "Heisenbridge is configured over the command line. Append extra arguments here";
104 default = [ ];
105 };
106 };
107
108 config = mkIf cfg.enable {
109 systemd.services.heisenbridge = {
110 description = "Matrix<->IRC bridge";
111 before = [ "matrix-synapse.service" ]; # So the registration file can be used by Synapse
112 wantedBy = [ "multi-user.target" ];
113
114 preStart = ''
115 umask 077
116 set -e -u -o pipefail
117
118 if ! [ -f "${registrationFile}" ]; then
119 # Generate registration file if not present (actually, we only care about the tokens in it)
120 ${bin} --generate --config ${registrationFile}
121 fi
122
123 # Overwrite the registration file with our generated one (the config may have changed since then),
124 # but keep the tokens. Two step procedure to be failure safe
125 ${pkgs.yq}/bin/yq --slurp \
126 '.[0] + (.[1] | {as_token, hs_token})' \
127 ${bridgeConfig} \
128 ${registrationFile} \
129 > ${registrationFile}.new
130 mv -f ${registrationFile}.new ${registrationFile}
131
132 # Grant Synapse access to the registration
133 if ${pkgs.getent}/bin/getent group matrix-synapse > /dev/null; then
134 chgrp -v matrix-synapse ${registrationFile}
135 chmod -v g+r ${registrationFile}
136 fi
137 '';
138
139 serviceConfig = rec {
140 Type = "simple";
141 ExecStart = lib.concatStringsSep " " (
142 [
143 bin
144 (if cfg.debug then "-vvv" else "-v")
145 "--config"
146 registrationFile
147 "--listen-address"
148 (lib.escapeShellArg cfg.address)
149 "--listen-port"
150 (toString cfg.port)
151 ]
152 ++ (lib.optionals (cfg.owner != null) [
153 "--owner"
154 (lib.escapeShellArg cfg.owner)
155 ])
156 ++ (lib.optionals cfg.identd.enable [
157 "--identd"
158 "--identd-port"
159 (toString cfg.identd.port)
160 ])
161 ++ [
162 (lib.escapeShellArg cfg.homeserver)
163 ]
164 ++ (map (lib.escapeShellArg) cfg.extraArgs)
165 );
166
167 # Hardening options
168
169 User = "heisenbridge";
170 Group = "heisenbridge";
171 RuntimeDirectory = "heisenbridge";
172 RuntimeDirectoryMode = "0700";
173 StateDirectory = "heisenbridge";
174 StateDirectoryMode = "0755";
175
176 ProtectSystem = "strict";
177 ProtectHome = true;
178 PrivateTmp = true;
179 PrivateDevices = true;
180 ProtectKernelTunables = true;
181 ProtectControlGroups = true;
182 RestrictSUIDSGID = true;
183 PrivateMounts = true;
184 ProtectKernelModules = true;
185 ProtectKernelLogs = true;
186 ProtectHostname = true;
187 ProtectClock = true;
188 ProtectProc = "invisible";
189 ProcSubset = "pid";
190 RestrictNamespaces = true;
191 RemoveIPC = true;
192 UMask = "0077";
193
194 CapabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024 || (cfg.identd.enable && cfg.identd.port < 1024)) "CAP_NET_BIND_SERVICE";
195 AmbientCapabilities = CapabilityBoundingSet;
196 NoNewPrivileges = true;
197 LockPersonality = true;
198 RestrictRealtime = true;
199 SystemCallFilter = ["@system-service" "~@privileged" "@chown"];
200 SystemCallArchitectures = "native";
201 RestrictAddressFamilies = "AF_INET AF_INET6";
202 };
203 };
204
205 users.groups.heisenbridge = {};
206 users.users.heisenbridge = {
207 description = "Service user for the Heisenbridge";
208 group = "heisenbridge";
209 isSystemUser = true;
210 };
211 };
212
213 meta.maintainers = [ ];
214}