Der Cron-Dienst war eigentlich immer das Mittel der Wahl, um wiederholt Aufgaben auszuführen. Mit Systemd ist die Funktion Timer hinzugekommen, über die man ebenfalls zeitgesteuert Programme ausführen kann. Dabei bekommt man dazu all die Möglichkeiten von Systemd, sprich die Werkzeuge zur Steuerung und die Möglichkeiten zur Rechtebegrenzung.

In der Regel handelt es sich bei den Aufgaben um mehr als einen einzigen Befehl, aber dennoch wäre es praktisch, alles kompakt beisammen zu haben. Viele dieser Skripte habe ich sonst unter /etc/cron.daily|weekly|monthly abgelegt, aber mit den Timer-Units lässt sich der Startzeitpunkt wesentlich besser steuern.

Ein Timer ist ein Unit-Typ, der eine Service-Unit starten kann. Die Einträge überlappen sich nicht und könnten meiner Ansicht nach auch in eine Unit (die Systemd dann vielleicht im Hintergrund aufteilt), aber leider habe ich dafür noch keine Lösung gefunden. Deshalb muss man zwei Units erstellen, wobei es sinnvoll ist, beiden den gleichen Namen zu geben.

Shellskripte direkt in Service-Units

Da diese kleinen Aufgaben nach der Ausführung beendet sind (und nicht wie Dienste dauerhaft laufen), muss man Type=oneshot definieren. Nützlich ist es auch mit SyslogIdentifier=… einen Namen zu vergeben, damit im Log nicht nur sh als Prozess steht.

Für Einträge in /etc/crontab war es nur sinnvoll, externe Skripte zu hinterlegen, denn mehrere Anweisungen in einer Zeile waren unlesbar. Mit den Cron-Skripten in /etc/cron.* hatte man den Vorteil, dass man dort mehrere Anweisungen in lesbarer Form hinterlegen konnte. Aber sobald man su aufgerufen und den Benutzer gewechselt hat, wurde auch alles wieder zu einem großen Zeichenfolge.

Für Systemd gibt es so eine Zwischenlösung: Man kann als Prozess die Shell, Perl, Python oder ein anderes Programm starten, dass über die Standardeingabe die Anweisungen entgegen nimmt und über StandardInputText kann man das Programm übergeben. Somit sind die Anweisungen auch nicht über die Prozessliste ps zu sehen.

Den Wert von StandardInputText kann man über mehrere Zeilen verteilen, wofür man am Ende jeder Zeile einen Backslash \ setzen muss. Da auch die gängigen Escape-Sequenzen unterstützt werden, kann man somit jede Zeile mit \n\ abschließen, da jeder Zeilenumbruch für die Shell ein Befehlsende darstellt. Alternativ könnte man auch (wie in Makefile üblich) ein Semikolon als Trenner der Befehle nutzen. Da ebenfalls die Sequenzen mit Prozentzeichen ersetzt werden, müssen diese entsprechend durch Verdoppelung %% maskiert werden.

Der Gesamtwert von StandardInputText lässt sich auch durch wiederholte Einträge zusammensetzen, wobei am Ende jedes Eintrags ein Zeilenumbruch eingefügt wird. Diese Form gefällt mir aber aufgrund der tieferen Einrückung und dem immer vorangehenden StandardInputText= nicht so gut, weshalb ich die Variante mit \n\ nutze.

Entsprechende Rechteeinschränkungen kann man dann mit den üblichen Systemd-Parametern vornehmen, womit sich eine Unit wie folgende ergibt (erstellen mit systemctl edit --full --force NAME.service):

[Unit]
Description=Download weather forcast image from meteoblue

[Service]
Type=oneshot
SyslogIdentifier=meteogram
User=joerg
Group=joerg
ExecStart=/bin/sh

StandardInput=data
StandardInputText=\
  dir=~/meteogram \n\
  fname=meteogram-Jena-$(date +%%Y-%%m-%%d\\ %%H:%%M).png \n\
  wget --no-verbose -O "$dir/$fname" \
  'https://my.meteoblue.com/visimage/meteogram_web_hd?…' \n\
  if cmp --quiet "$dir/$fname" "$dir/latest"; then \n\
    rm "$dir/$fname" \n\
    echo "Removed duplicate" \n\
  else \n\
    ln -sf "$fname" "dir"/latest \n\
  fi

LockPersonality=true
NoNewPrivileges=true
CapabilityBoundingSet=
PrivateDevices=true
PrivateTmp=true
ProtectClock=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
RemoveIPC=true
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=@system-service

Da dies eine ganz normale Service-Unit ist, kann man sie auch mit systemctl start NAME ausführen und sich im Log die entsprechenden Einträge mit journalctl -b -u NAME ansehen.

Timer-Unit

Um regelmäßig diese Service-Unit zu starten, muss man eine Timer-Unit definieren. Im Abschnitt [Timer] kann man über OnCalendar mit einer recht komplexen Syntax die Zeitpunkte definieren. Mit *:20,50 wird zum Beispiel 20 und 50 Minuten nach jeder vollen Stunde der Timer ausgelöst. Zur Prüfung der Spezifikation kann man systemd-analyze calendar '…' verwenden.

Weil ich auf ein externes System zugreife und dessen Last etwas streuen will, habe ich mit RandomizedDelaySec=10m eine Schwankungsbreite von 10 Minuten für die Ausführung angegeben. Hier in diesem Fall mag es keine praktische Bedeutung haben, aber bei lokalen Aufgaben oder internen Diensten, ist es hilfreich, wenn nicht exakt 4 Uhr alle Systeme auf einen Dienst einhämmern und die restlichen 24 Stunden passiert nichts.

Sollte die Service-Unit einen abweichenden Namen von der Timer-Unit haben, muss man diese mit Unit= angeben. Für den Timer ist es – entgegen dem Service – wichtig, mit WantedBy anzugeben, dass er beim Start des Systems automatisch aktiviert wird. Wenn man dies nicht tut, muss man ihn immer wieder von Hand mit systemctl start NAME.timer aktivieren.

[Unit]
Description=Trigger download of meteogram image

[Timer]
# Unit=…
OnCalendar=*:20,50
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target

Nachdem die Unit mit systemctl edit --full --force NAME.timer erstellt und mit systemctl enable --now NAME.timer registriert und gestartet wurde, kann mit systemctl list-timers der Zustand der Timer erfragt werden:

NEXT                         LEFT       LAST                         PASSED       UNIT            ACTIVATES
Tue 2021-05-11 08:55:23 CEST 27min left Tue 2021-05-11 05:14:12 CEST 3h 14min ago meteogram.timer meteogram.service

1 timers listed.
Pass --all to see loaded but inactive timers, too.

Logüberwachung mit einem Timer

Auf die gleiche Weise kann man auch einen Timer erstellen, der regelmäßig im Journal nachsieht, ob Fehlermeldungen aufgetreten sind und diese dann per E-Mail meldet. Hierfür ist die Option --cursor-file für journalctl hilfreich, da man mit ihr die neusten Meldungen im Journal abfragen kann:

# /etc/systemd/system/journal-report.service
[Unit]
Description=Report about error messages in journal to root

[Service]
Type=oneshot
SyslogIdentifier=err-report
ExecStart=/bin/sh

StandardInput=data
StandardInputText=\
  out=$(journalctl --cursor-file /var/local/error.cur --since=-2\\ days --no-hostname --no-tail -p err) \n\
  if ! echo "$out" | grep -q -e '-- No entries --' \n\
  then \
    echo "$out" |mail -s "[`hostname`] error log" root || exit \n\
    echo "Found $(echo "$out" | wc -l) error messages. Sent e-mail." \n\
  fi

# Common security settings https://jo-so.de/2021-05/H%C3%A4rtung-Systemd.html
CapabilityBoundingSet=

LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true

PrivateDevices=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict

# Make sure that the process can only see PIDs and process details of itself,
# and the second option disables seeing details of things like system load and
# I/O etc
ProtectProc=invisible
ProcSubset=pid

RemoveIPC=true
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true

SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
# end of common security settings

# Allow suid-exec exim
NoNewPrivileges=false
SecureBits=no-setuid-fixup

# required for Exim's mail delivery
CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_SETGID CAP_SETUID

# write access to /var required by Exim
ProtectSystem=full

# required by Exim
SystemCallFilter=@chown @setuid

# /etc/systemd/system/journal-report.timer
[Unit]
Description=Trigger report about errors in journal

[Timer]
OnCalendar=*:10/10

[Install]
WantedBy=timers.target

Zum Test kann man die Service-Unit einfach starten und ggf. den Cursor /var/local/error.cur entfernen, um wieder von vorn zu beginnen. Der Timer läuft im 10-Minuten-Takt, aber kann auch seltener gestartet werden.

Problematisch ist der Aufruf von mail, da dieser je nach Mail-Dienst und Konfiguration noch mehr Rechte benötigt, um die E‑Mail auszuliefern. In diesem Fall muss CapabilityBoundingSet oder SystemCallFilter erweitert werden.

Statt der Filteroption -p err kann man auch die Warnungen mit -p warning einbeziehen oder mit -t audit sich regelmäßig über die Verstöße gegen Sicherheitsrichtlinien (AppArmor, Seccomp) informieren lassen.