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