fd27ae5ed984c08e7b5b9f3d92d4ac15c6c75797
[distro-setup] / mailtest-check
1 #!/bin/bash
2 # I, Ian Kelling, follow the GNU license recommendations at
3 # https://www.gnu.org/licenses/license-recommendations.en.html. They
4 # recommend that small programs, < 300 lines, be licensed under the
5 # Apache License 2.0. This file contains or is part of one or more small
6 # programs. If a small program grows beyond 300 lines, I plan to switch
7 # its license to GPL.
8
9 # Copyright 2024 Ian Kelling
10
11 # Licensed under the Apache License, Version 2.0 (the "License");
12 # you may not use this file except in compliance with the License.
13 # You may obtain a copy of the License at
14
15 # http://www.apache.org/licenses/LICENSE-2.0
16
17 # Unless required by applicable law or agreed to in writing, software
18 # distributed under the License is distributed on an "AS IS" BASIS,
19 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 # See the License for the specific language governing permissions and
21 # limitations under the License.
22
23
24 # Usage: mail-test-check [slow] [int|nonint]
25 #
26 # slow: do slow checks, like spamassassin
27 #
28 # for non-interactive, dont print unless something went
29 # wrong
30
31
32 source /b/bash-bear-trap/bash-bear
33
34 [[ $EUID == 0 ]] || exec sudo -E "${BASH_SOURCE[0]}" "$@"
35
36 shopt -s nullglob
37
38 e() { $int || return 0; printf "mailtest-check: %s\n" "$*"; }
39
40 getspamdpid() {
41 if [[ ! $spamdpid || ! -d /proc/$spamdpid ]]; then
42 # try twice in case we are restarting, it happens.
43 for (( i=0; i<2; i++ )); do
44 spamdpid=$(systemctl show --property MainPID --value spamassassin | sed 's/^[10]$//' ||:)
45 if [[ $spamdpid ]]; then
46 break
47 fi
48 sleep 30
49 done
50 fi
51 }
52
53
54 #### begin arg processing ####
55 # spamassassin checking takes about 8 seconds.
56 slow=false
57 if [[ $1 == slow ]]; then
58 slow=true
59 shift
60 fi
61
62 int=false
63 if [[ $SUDO_USER || $SSH_CONNECTION ]]; then
64 int=true
65 fi
66
67 if [[ $1 == int ]]; then
68 int=true
69 fi
70
71 if [[ $1 == nonint ]]; then
72 int=false
73 fi
74 #### end arg processing ####
75
76 # we put this in to avoid dns errors that happen on reboot,
77 # but I want to debug them.
78 # if ! $int; then
79 # sleep 60
80 # fi
81
82
83 # TODO, get je to deliver the local mailbox: /m/md/INBOX
84 # dovecot appears to setup, i can t be sure.
85
86 spamd_ser=spamd
87 if systemctl cat spamassassin &>/dev/null; then
88 spamd_ser=spamassassin
89 fi
90
91 source /a/bin/bash_unpublished/source-state
92
93 doprom=false
94 case $HOSTNAME in
95 $MAIL_HOST|bk|je)
96 doprom=true
97 ;;
98 *)
99 rm -f /var/lib/prometheus/node-exporter/mailtest-check.prom*
100 ;;
101 esac
102
103 main() {
104
105 local -a p_unexpected_spamd_results p_missing_dnswl p_last_usec
106 case $HOSTNAME in
107 bk)
108 folders=(/m/md/{expertpathologyreview.com,amnimal.ninja}/testignore)
109 froms=(ian@iankelling.org z@zroe.org testignore@je.b8.nz iank@gnu.org)
110 ;;
111 je)
112 froms=(ian@iankelling.org z@zroe.org iank@gnu.org testignore@amnimal.ninja)
113 folders=(/m/md/je.b8.nz/testignore)
114 ;;
115 *)
116 folders=(/m/md/l/testignore)
117 froms=(testignore@je.b8.nz testignore@expertpathologyreview.com testignore@amnimal.ninja ian@iankelling.org z@zroe.org)
118 if ! $int; then
119 ### begin rsyncing fencepost email ###
120 # We dont want to exit if rsync fails, that will get caught by
121 # our later test by virtue of not having the latest email.
122 did_rsync=false
123 try_start_time=$EPOCHSECONDS
124 try_limit=140 # somewhat arbitrary value
125 while ! $did_rsync; do
126 try_left=$(( try_limit - ( EPOCHSECONDS - try_start_time) ))
127 timeout=120 # somewhat arbitrary value
128 if (( try_left < 0 )); then
129 echo "mailtest-check: failed to rsync fencepost > $try_limit seconds"
130 break
131 fi
132 if (( try_left < timeout )); then
133 timeout=$try_left
134 fi
135 if timeout $timeout rsync --chown iank:iank -e "ssh -oIdentitiesOnly=yes -F /dev/null -i /root/.ssh/jtuttle" -t --inplace -r 'jtuttle@fencepost.gnu.org:/home/j/jtuttle/Maildir/new/' /m/md/l/testignore/new; then
136 did_rsync=true
137 else
138 sleep 4
139 fi
140 done
141 if ! $did_rsync; then
142 echo mailtest-check: warning: fencepost rsync failed
143 fi
144 ### end rsyncing fencepost email ###
145 fi
146 ;;
147 esac
148
149
150 # avoid errors like this:
151 # Nov 8 08:16:05.439 [6080] warn: plugin: failed to parse plugin (from @INC): Can't locate Mail/SpamAssassin/Plugin/WLBLEval.pm: lib/Mail/SpamAssassin/Plugin/WLBLEval.pm: Permission denied at (eval 59) line 1.
152 #Nov 8 08:16:05.439 [6080] warn: plugin: failed to parse plugin (from @INC): Can't locate Mail/SpamAssassin/Plugin/VBounce.pm: lib/Mail/SpamAssassin/Plugin/VBounce.pm: Permission denied at (eval 60) line 1.
153 # i dont know why, i just found the solution online
154 cd /m/md
155
156
157 getspamdpid
158 # first time we write, overwrite anything existing
159 if [[ -e /var/lib/prometheus/node-exporter ]]; then
160 cat >/var/lib/prometheus/node-exporter/mailtest-check.prom.$$ <<EOF
161 mailtest_check_found_spamd_pid_bool $(( ${spamdpid:-0} > 0 ))
162 EOF
163 fi
164 e spamdpid: $spamdpid
165 if [[ ! $spamdpid ]]; then
166 echo mailtest spamd pid not found. systemctl status $spamd_ser:
167 systemctl status $spamd_ser
168 fi
169 tmpfile=$(mktemp)
170 declare -i unexpected=0
171 for folder in ${folders[@]}; do
172 for from in ${froms[@]}; do
173 declare -i missing_dnswl=0
174 #declare -i dnsfail=0
175 declare -i unexpected=0
176 latest=
177 last_sec=0
178
179 if ! grep -rlFx "From: $from" $folder/{new,cur} >$tmpfile; then
180 echo "no message found from: $from"
181 continue
182 fi
183 # webmail sends them to cur it seems
184 while read -r file; do
185 file_sec=$(awk '/^Subject: / {print $4}' $file)
186 if [[ $file_sec ]] && (( file_sec > last_sec )); then
187 latest=$file
188 last_sec="$file_sec"
189 fi
190 done <$tmpfile
191 rm -f $tmpfile
192
193 to=$(awk '/^Envelope-to: / {print $2}' $latest)
194
195 if $slow; then
196 if ! $int; then
197 find $folder/new $folder/cur -type f -mmin +1080 -delete
198 fi
199 getspamdpid
200 if [[ $spamdpid ]]; then
201 if [[ $(readlink /proc/$$/ns/net) != "$(readlink /proc/$spamdpid/ns/net)" ]]; then
202 spamcpre="nsenter -t $spamdpid -n -m"
203 fi
204 unset results
205 declare -A results
206 # pyzor fails for our test message, so dont put useless load on their
207 # servers.
208 # example line that sed is parsing:
209 # (-0.1 / 5.0 requ) DKIM_SIGNED=0.1,DKIM_VALID=-0.1,DKIM_VALID_AU=-0.1,SPF_HELO_PASS=-0.001,SPF_PASS=-0.001,TVD_SPACE_RATIO=0.001 autolearn=_AUTOLEARN
210 resultfile=$(mktemp)
211 $spamcpre sudo -u Debian-exim spamassassin -D -t --cf='score PYZOR_CHECK 0' <"$latest" &>$resultfile
212
213 # note: on some mail, its 1 line after the send-test-forward, on others its 2 with a blank inbetween.
214 # I use the sed -n to filter this.
215 raw_results="$(tail $resultfile | grep -A2 -Fx /usr/local/bin/send-test-forward | tail -n+2 | sed -nr 's/^\([^)]*\) *//;s/=[^, ]*([, ]|$)/ /gp')"
216 for r in $raw_results; do
217 case $r in
218 # got this in an update 2022-01. dun care
219 T_SCC_BODY_TEXT_LINE|SCC_BODY_SINGLE_WORD) : ;;
220 # we have a new domain, ignore this.
221 # it seems like some versions of spamassassin do BODY_SINGLE_WORD, others dont, we dun care.
222 # bayes_00 is a new one indicating ham, we dont care if its missing.
223 BAYES_00|BODY_SINGLE_WORD|FROM_FMBLA_NEWDOM*|autolearn) : ;;
224
225 # These have somewhat randomly been added and removed, resulting in useless alerts, so ignore them.
226 RCVD_IN_DNSWL_MED|DKIMWL_WL_HIGH) : ;;
227
228 SPF_HELO_NEUTRAL)
229 # some of my domains use neutral spf, treat them the same.
230 results[SPF_HELO_PASS]=t
231 ;;
232 *)
233 results[$r]=t
234 ;;
235 esac
236 done
237 # debugging
238 # e results = ${!results[@]}
239 missing=()
240
241 keys=(DKIM_SIGNED DKIM_VALID{,_AU,_EF} SPF_HELO_PASS SPF_PASS TVD_SPACE_RATIO)
242 if [[ $to == *@gnu.org && $from == *@gnu.org ]]; then
243 keys=(ALL_TRUSTED TVD_SPACE_RATIO)
244 # from eggs had DKIMWL_WL_HIGH sometime in 2022, then DKIMWL_WL_MED unti march 2023
245 fi
246
247 for t in ${keys[@]}; do
248 if [[ ${results[$t]} ]]; then
249 unset "results[$t]"
250 elif [[ $t == DKIM_VALID_EF && $from == *@[^.]*.[^.]*.[^.]* ]]; then
251 :
252 # third level domains dont hit this. its because
253 # /usr/share/perl5/Mail/SpamAssassin/Plugin/DKIM.pm checks
254 # if its signed with the registryboundaries domain. afaik:
255 # we need the actual domain to sign it, this would result in
256 # a second signature. I only use second level domains for
257 # testing atm, fsf doesnt use them for anything but the
258 # forum and I dont expect that to have any deliverability
259 # problems. So, not bothering atm.
260 else
261 missing+=($t)
262 fi
263 done
264 if (( ${#results[@]} || ${#missing[@]} )); then
265 printf "$HOSTNAME spamtest %s\n" "$latest"
266 if (( ${#results[@]} )); then
267 printf "unexpected %s" "${!results[*]} "
268 fi
269 if (( ${#missing[@]} )); then
270 printf "missing %s" "${missing[*]}"
271 fi
272 echo # ends our printf string buildup
273 cat $resultfile
274 echo mailtest-check: end of spam debug results
275 # lets just handle 1 failure at a time in interactive mode.
276 if $int; then
277 echo mailtest-check: from: $from, to: $to
278 exit 0
279 fi
280
281 # less verbose debug output, commented since I might want it another time.
282 # if $int; then
283 # echo mailtest-check: cat $latest:
284 # cat $latest
285 # echo mailtest-check: end of cat
286 #fi
287 fi
288 rm -f $resultfile
289 for r in ${results[@]}; do
290 case $r in
291 # iank: for when we want to handle dns errors differently.
292 # also uncomment declaration of dnsfail above.
293 # DKIM_INVALID|T_SPF_TEMPERROR|T_SPF_HELO_TEMPERROR)
294 # dnsfail+=1
295 # ;;
296 *)
297 unexpected=$(( unexpected + 1 ))
298 ;;
299 esac
300 done
301 for miss in ${missing[@]}; do
302 # At some point we had annoying dns failures that we couldn't solve so we
303 # we counted dns fail related results separately and alert differently.
304 # DKIM_VALID|DKIM_VALID_AU|DKIM_VALID_EF|SPF_HELO_PASS|SPF_PASS|
305 case $miss in
306 *)
307 unexpected+=1
308 ;;
309 esac
310 done
311 mapfile -O ${#p_missing_dnswl[@]} -t p_missing_dnswl <<EOF
312 mailtest_check_missing_dnswl{folder="$folder",from="$from"} $missing_dnswl
313 EOF
314 mapfile -O ${#p_unexpected_spamd_results[@]} -t p_unexpected_spamd_results <<EOF
315 mailtest_check_unexpected_spamd_results{folder="$folder",from="$from"} $unexpected
316 EOF
317 fi # if spamdpid
318 fi # if $slow
319
320 now=$EPOCHSECONDS
321 age_sec=$(( now - last_sec ))
322 e $((age_sec / 60)):$(( age_sec % 60 )) ago. to:$to from:$from $latest
323
324 # usec = unix seconds
325 mapfile -O ${#p_last_usec[@]} -t p_last_usec <<EOF
326 mailtest_check_last_usec{folder="$folder",from="$from"} $last_sec
327 EOF
328 done # end for from in ${froms[@]}
329 done # end for folder in ${folders[@]}
330
331 dir=/var/lib/prometheus/node-exporter
332 path=$dir/mailtest-check.prom.$$
333 if $doprom && [[ -e $dir ]]; then
334 for l in "${p_unexpected_spamd_results[@]}"; do
335 printf "%s\n" "$l" >>$path
336 done
337 for l in "${p_missing_dnswl[@]}"; do
338 printf "%s\n" "$l" >>$path
339 done
340 for l in "${p_last_usec[@]}"; do
341 printf "%s\n" "$l" >>$path
342 done
343 mv $path $dir/mailtest-check.prom
344 # note: node_textfile_mtime_seconds will tell us when this last happened. useful for debugging.
345 fi
346 }
347
348 loop-main() {
349 # When running under systemd, the system just started. Ve nice and
350 # give programs some time to finish their startup.
351 sleep 10
352 while true; do
353 premain_sec=$EPOCHSECONDS
354 main
355 sleep $(( 300 - ( EPOCHSECONDS - premain_sec ) ))
356 done
357 }
358
359
360 if [[ $INVOCATION_ID ]]; then
361 loop-main
362 else
363 main
364 fi
365
366 exit 0