1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.journalwatch;
9 user = "journalwatch";
10 # for journal access
11 group = "systemd-journal";
12 dataDir = "/var/lib/${user}";
13
14 journalwatchConfig = pkgs.writeText "config" (
15 ''
16 # (File Generated by NixOS journalwatch module.)
17 [DEFAULT]
18 mail_binary = ${cfg.mailBinary}
19 priority = ${toString cfg.priority}
20 mail_from = ${cfg.mailFrom}
21 ''
22 + lib.optionalString (cfg.mailTo != null) ''
23 mail_to = ${cfg.mailTo}
24 ''
25 + cfg.extraConfig
26 );
27
28 journalwatchPatterns = pkgs.writeText "patterns" ''
29 # (File Generated by NixOS journalwatch module.)
30
31 ${mkPatterns cfg.filterBlocks}
32 '';
33
34 # empty line at the end needed to to separate the blocks
35 mkPatterns =
36 filterBlocks:
37 lib.concatStringsSep "\n" (
38 map (block: ''
39 ${block.match}
40 ${block.filters}
41
42 '') filterBlocks
43 );
44
45 # can't use joinSymlinks directly, because when we point $XDG_CONFIG_HOME
46 # to the /nix/store path, we still need the subdirectory "journalwatch" inside that
47 # to match journalwatch's expectations
48 journalwatchConfigDir =
49 pkgs.runCommand "journalwatch-config"
50 {
51 preferLocalBuild = true;
52 allowSubstitutes = false;
53 }
54 ''
55 mkdir -p $out/journalwatch
56 ln -sf ${journalwatchConfig} $out/journalwatch/config
57 ln -sf ${journalwatchPatterns} $out/journalwatch/patterns
58 '';
59
60in
61{
62 options = {
63 services.journalwatch = {
64 enable = lib.mkOption {
65 type = lib.types.bool;
66 default = false;
67 description = ''
68 If enabled, periodically check the journal with journalwatch and report the results by mail.
69 '';
70 };
71
72 package = lib.mkPackageOption pkgs "journalwatch" { };
73
74 priority = lib.mkOption {
75 type = lib.types.int;
76 default = 6;
77 description = ''
78 Lowest priority of message to be considered.
79 A value between 7 ("debug"), and 0 ("emerg"). Defaults to 6 ("info").
80 If you don't care about anything with "info" priority, you can reduce
81 this to e.g. 5 ("notice") to considerably reduce the amount of
82 messages without needing many {option}`filterBlocks`.
83 '';
84 };
85
86 # HACK: this is a workaround for journalwatch's usage of socket.getfqdn() which always returns localhost if
87 # there's an alias for the localhost on a separate line in /etc/hosts, or take for ages if it's not present and
88 # then return something right-ish in the direction of /etc/hostname. Just bypass it completely.
89 mailFrom = lib.mkOption {
90 type = lib.types.str;
91 default = "journalwatch@${config.networking.hostName}";
92 defaultText = lib.literalExpression ''"journalwatch@''${config.networking.hostName}"'';
93 description = ''
94 Mail address to send journalwatch reports from.
95 '';
96 };
97
98 mailTo = lib.mkOption {
99 type = lib.types.nullOr lib.types.str;
100 default = null;
101 description = ''
102 Mail address to send journalwatch reports to.
103 '';
104 };
105
106 mailBinary = lib.mkOption {
107 type = lib.types.path;
108 default = "/run/wrappers/bin/sendmail";
109 description = ''
110 Sendmail-compatible binary to be used to send the messages.
111 '';
112 };
113
114 extraConfig = lib.mkOption {
115 type = lib.types.str;
116 default = "";
117 description = ''
118 Extra lines to be added verbatim to the journalwatch/config configuration file.
119 You can add any commandline argument to the config, without the '--'.
120 See `journalwatch --help` for all arguments and their description.
121 '';
122 };
123
124 filterBlocks = lib.mkOption {
125 type = lib.types.listOf (
126 lib.types.submodule {
127 options = {
128 match = lib.mkOption {
129 type = lib.types.str;
130 example = "SYSLOG_IDENTIFIER = systemd";
131 description = ''
132 Syntax: `field = value`
133 Specifies the log entry `field` this block should apply to.
134 If the `field` of a message matches this `value`,
135 this patternBlock's {option}`filters` are applied.
136 If `value` starts and ends with a slash, it is interpreted as
137 an extended python regular expression, if not, it's an exact match.
138 The journal fields are explained in {manpage}`systemd.journal-fields(7)`.
139 '';
140 };
141
142 filters = lib.mkOption {
143 type = lib.types.str;
144 example = ''
145 (Stopped|Stopping|Starting|Started) .*
146 (Reached target|Stopped target) .*
147 '';
148 description = ''
149 The filters to apply on all messages which satisfy {option}`match`.
150 Any of those messages that match any specified filter will be removed from journalwatch's output.
151 Each filter is an extended Python regular expression.
152 You can specify multiple filters and separate them by newlines.
153 Lines starting with '#' are comments. Inline-comments are not permitted.
154 '';
155 };
156 };
157 }
158 );
159
160 example = [
161 # examples taken from upstream
162 {
163 match = "_SYSTEMD_UNIT = systemd-logind.service";
164 filters = ''
165 New session [a-z]?\d+ of user \w+\.
166 Removed session [a-z]?\d+\.
167 '';
168 }
169
170 {
171 match = "SYSLOG_IDENTIFIER = /(CROND|crond)/";
172 filters = ''
173 pam_unix\(crond:session\): session (opened|closed) for user \w+
174 \(\w+\) CMD .*
175 '';
176 }
177 ];
178
179 # another example from upstream.
180 # very useful on priority = 6, and required as journalwatch throws an error when no pattern is defined at all.
181 default = [
182 {
183 match = "SYSLOG_IDENTIFIER = systemd";
184 filters = ''
185 (Stopped|Stopping|Starting|Started) .*
186 (Created slice|Removed slice) user-\d*\.slice\.
187 Received SIGRTMIN\+24 from PID .*
188 (Reached target|Stopped target) .*
189 Startup finished in \d*ms\.
190 '';
191 }
192 ];
193
194 description = ''
195 filterBlocks can be defined to blacklist journal messages which are not errors.
196 Each block matches on a log entry field, and the filters in that block then are matched
197 against all messages with a matching log entry field.
198
199 All messages whose PRIORITY is at least 6 (INFO) are processed by journalwatch.
200 If you don't specify any filterBlocks, PRIORITY is reduced to 5 (NOTICE) by default.
201
202 All regular expressions are extended Python regular expressions, for details
203 see: http://doc.pyschools.com/html/regex.html
204 '';
205 };
206
207 interval = lib.mkOption {
208 type = lib.types.str;
209 default = "hourly";
210 description = ''
211 How often to run journalwatch.
212
213 The format is described in {manpage}`systemd.time(7)`.
214 '';
215 };
216 accuracy = lib.mkOption {
217 type = lib.types.str;
218 default = "10min";
219 description = ''
220 The time window around the interval in which the journalwatch run will be scheduled.
221
222 The format is described in {manpage}`systemd.time(7)`.
223 '';
224 };
225 };
226 };
227
228 config = lib.mkIf cfg.enable {
229
230 users.users.${user} = {
231 isSystemUser = true;
232 home = dataDir;
233 group = group;
234 };
235
236 systemd.tmpfiles.rules = [
237 # present since NixOS 19.09: remove old stateful symlink join directory,
238 # which has been replaced with the journalwatchConfigDir store path
239 "R ${dataDir}/config"
240 ];
241
242 systemd.services.journalwatch = {
243
244 environment = {
245 # journalwatch stores the last processed timpestamp here
246 # the share subdirectory is historic now that config home lives in /nix/store,
247 # but moving this in a backwards-compatible way is much more work than what's justified
248 # for cleaning that up.
249 XDG_DATA_HOME = "${dataDir}/share";
250 XDG_CONFIG_HOME = journalwatchConfigDir;
251 };
252 serviceConfig = {
253 User = user;
254 Group = group;
255 Type = "oneshot";
256 # requires a relative directory name to create beneath /var/lib
257 StateDirectory = user;
258 StateDirectoryMode = "0750";
259 ExecStart = "${lib.getExe cfg.package} mail";
260 # lowest CPU and IO priority, but both still in best-effort class to prevent starvation
261 Nice = 19;
262 IOSchedulingPriority = 7;
263 };
264 };
265
266 systemd.timers.journalwatch = {
267 description = "Periodic journalwatch run";
268 wantedBy = [ "timers.target" ];
269 timerConfig = {
270 OnCalendar = cfg.interval;
271 AccuracySec = cfg.accuracy;
272 Persistent = true;
273 };
274 };
275
276 };
277
278 meta = {
279 maintainers = with lib.maintainers; [ florianjacob ];
280 };
281}