1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.mjolnir;
9
10 yamlConfig = {
11 inherit (cfg) dataPath managementRoom protectedRooms;
12
13 accessToken = "@ACCESS_TOKEN@"; # will be replaced in "generateConfig"
14 homeserverUrl =
15 if cfg.pantalaimon.enable then
16 "http://${cfg.pantalaimon.options.listenAddress}:${toString cfg.pantalaimon.options.listenPort}"
17 else
18 cfg.homeserverUrl;
19
20 rawHomeserverUrl = cfg.homeserverUrl;
21
22 pantalaimon = {
23 inherit (cfg.pantalaimon) username;
24
25 use = cfg.pantalaimon.enable;
26 password = "@PANTALAIMON_PASSWORD@"; # will be replaced in "generateConfig"
27 };
28 };
29
30 moduleConfigFile = pkgs.writeText "module-config.yaml" (
31 lib.generators.toYAML { } (
32 lib.filterAttrs (_: v: v != null) (
33 lib.fold lib.recursiveUpdate { } [
34 yamlConfig
35 cfg.settings
36 ]
37 )
38 )
39 );
40
41 # these config files will be merged one after the other to build the final config
42 configFiles = [
43 "${pkgs.mjolnir}/lib/node_modules/mjolnir/config/default.yaml"
44 moduleConfigFile
45 ];
46
47 # this will generate the default.yaml file with all configFiles as inputs and
48 # replace all secret strings using replace-secret
49 generateConfig = pkgs.writeShellScript "mjolnir-generate-config" (
50 let
51 yqEvalStr = lib.concatImapStringsSep " * " (
52 pos: _: "select(fileIndex == ${toString (pos - 1)})"
53 ) configFiles;
54 yqEvalArgs = lib.concatStringsSep " " configFiles;
55 in
56 ''
57 set -euo pipefail
58
59 umask 077
60
61 # mjolnir will try to load a config from "./config/default.yaml" in the working directory
62 # -> let's place the generated config there
63 mkdir -p ${cfg.dataPath}/config
64
65 # merge all config files into one, overriding settings of the previous one with the next config
66 # e.g. "eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' filea.yaml fileb.yaml" will merge filea.yaml with fileb.yaml
67 ${pkgs.yq-go}/bin/yq eval-all -P '${yqEvalStr}' ${yqEvalArgs} > ${cfg.dataPath}/config/default.yaml
68
69 ${lib.optionalString (cfg.accessTokenFile != null) ''
70 ${pkgs.replace-secret}/bin/replace-secret '@ACCESS_TOKEN@' '${cfg.accessTokenFile}' ${cfg.dataPath}/config/default.yaml
71 ''}
72 ${lib.optionalString (cfg.pantalaimon.passwordFile != null) ''
73 ${pkgs.replace-secret}/bin/replace-secret '@PANTALAIMON_PASSWORD@' '${cfg.pantalaimon.passwordFile}' ${cfg.dataPath}/config/default.yaml
74 ''}
75 ''
76 );
77in
78{
79 options.services.mjolnir = {
80 enable = lib.mkEnableOption "Mjolnir, a moderation tool for Matrix";
81
82 homeserverUrl = lib.mkOption {
83 type = lib.types.str;
84 default = "https://matrix.org";
85 description = ''
86 Where the homeserver is located (client-server URL).
87
88 If `pantalaimon.enable` is `true`, this option will become the homeserver to which `pantalaimon` connects.
89 The listen address of `pantalaimon` will then become the `homeserverUrl` of `mjolnir`.
90 '';
91 };
92
93 accessTokenFile = lib.mkOption {
94 type = with lib.types; nullOr path;
95 default = null;
96 description = ''
97 File containing the matrix access token for the `mjolnir` user.
98 '';
99 };
100
101 pantalaimon = lib.mkOption {
102 description = ''
103 `pantalaimon` options (enables E2E Encryption support).
104
105 This will create a `pantalaimon` instance with the name "mjolnir".
106 '';
107 default = { };
108 type = lib.types.submodule {
109 options = {
110 enable = lib.mkEnableOption ''
111 ignoring the accessToken. If true, accessToken is ignored and the username/password below will be
112 used instead. The access token of the bot will be stored in the dataPath
113 '';
114
115 username = lib.mkOption {
116 type = lib.types.str;
117 description = "The username to login with.";
118 };
119
120 passwordFile = lib.mkOption {
121 type = with lib.types; nullOr path;
122 default = null;
123 description = ''
124 File containing the matrix password for the `mjolnir` user.
125 '';
126 };
127
128 options = lib.mkOption {
129 type = lib.types.submodule (import ./pantalaimon-options.nix);
130 default = { };
131 description = ''
132 passthrough additional options to the `pantalaimon` service.
133 '';
134 };
135 };
136 };
137 };
138
139 dataPath = lib.mkOption {
140 type = lib.types.path;
141 default = "/var/lib/mjolnir";
142 description = ''
143 The directory the bot should store various bits of information in.
144 '';
145 };
146
147 managementRoom = lib.mkOption {
148 type = lib.types.str;
149 default = "#moderators:example.org";
150 description = ''
151 The room ID where people can use the bot. The bot has no access controls, so
152 anyone in this room can use the bot - secure your room!
153 This should be a room alias or room ID - not a matrix.to URL.
154 Note: `mjolnir` is fairly verbose - expect a lot of messages from it.
155 '';
156 };
157
158 protectedRooms = lib.mkOption {
159 type = lib.types.listOf lib.types.str;
160 default = [ ];
161 example = lib.literalExpression ''
162 [
163 "https://matrix.to/#/#yourroom:example.org"
164 "https://matrix.to/#/#anotherroom:example.org"
165 ]
166 '';
167 description = ''
168 A list of rooms to protect (matrix.to URLs).
169 '';
170 };
171
172 settings = lib.mkOption {
173 default = { };
174 type = (pkgs.formats.yaml { }).type;
175 example = lib.literalExpression ''
176 {
177 autojoinOnlyIfManager = true;
178 automaticallyRedactForReasons = [ "spam" "advertising" ];
179 }
180 '';
181 description = ''
182 Additional settings (see [mjolnir default config](https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml) for available settings). These settings will override settings made by the module config.
183 '';
184 };
185 };
186
187 config = lib.mkIf config.services.mjolnir.enable {
188 assertions = [
189 {
190 assertion = !(cfg.pantalaimon.enable && cfg.pantalaimon.passwordFile == null);
191 message = "Specify pantalaimon.passwordFile";
192 }
193 {
194 assertion = !(cfg.pantalaimon.enable && cfg.accessTokenFile != null);
195 message = "Do not specify accessTokenFile when using pantalaimon";
196 }
197 {
198 assertion = !(!cfg.pantalaimon.enable && cfg.accessTokenFile == null);
199 message = "Specify accessTokenFile when not using pantalaimon";
200 }
201 ];
202
203 # This defaults to true in the application,
204 # which breaks older configs using pantalaimon or access tokens
205 services.mjolnir.settings.encryption.use = lib.mkDefault false;
206
207 services.pantalaimon-headless.instances."mjolnir" =
208 lib.mkIf cfg.pantalaimon.enable {
209 homeserver = cfg.homeserverUrl;
210 }
211 // cfg.pantalaimon.options;
212
213 systemd.services.mjolnir = {
214 description = "mjolnir - a moderation tool for Matrix";
215 wants = [
216 "network-online.target"
217 ] ++ lib.optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
218 after = [
219 "network-online.target"
220 ] ++ lib.optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
221 wantedBy = [ "multi-user.target" ];
222
223 serviceConfig = {
224 ExecStart = ''${pkgs.mjolnir}/bin/mjolnir --mjolnir-config ./config/default.yaml'';
225 ExecStartPre = [ generateConfig ];
226 WorkingDirectory = cfg.dataPath;
227 StateDirectory = "mjolnir";
228 StateDirectoryMode = "0700";
229 ProtectSystem = "strict";
230 ProtectHome = true;
231 PrivateTmp = true;
232 NoNewPrivileges = true;
233 PrivateDevices = true;
234 User = "mjolnir";
235 Restart = "on-failure";
236
237 /*
238 TODO: wait for #102397 to be resolved. Then load secrets from $CREDENTIALS_DIRECTORY+"/NAME"
239 DynamicUser = true;
240 LoadCredential = [] ++
241 lib.optionals (cfg.accessTokenFile != null) [
242 "access_token:${cfg.accessTokenFile}"
243 ] ++
244 lib.optionals (cfg.pantalaimon.passwordFile != null) [
245 "pantalaimon_password:${cfg.pantalaimon.passwordFile}"
246 ];
247 */
248 };
249 };
250
251 users = {
252 users.mjolnir = {
253 group = "mjolnir";
254 isSystemUser = true;
255 };
256 groups.mjolnir = { };
257 };
258 };
259
260 meta = {
261 doc = ./mjolnir.md;
262 maintainers = with lib.maintainers; [ jojosch ];
263 };
264}