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