Den Server absichern
Motivation
OK. Nun läuft mein Server, baut automatisch meinen Blog und zeigt ihn auch problemlos an. So ganz fertig bin ich aber noch nicht. Denn ich könnte mehr für die Sicherheit des Servers tun, als überall Passwort-Logins zu deaktivieren.
Außerdem habe ich noch keine Backups. Und das ist ja nie gut. Also, packen wir’s an!
Unerwünschte Besucher aussperren
Ich bekomme in meinem SSH-Log im Root-Account des Servers einen Haufen Verbindungsversuche, die nicht von mir kommen:
[...]
Jan 11 00:19:29 sshd[93842]: Invalid user admin from 41.207.248.204 port 37194
Jan 11 00:19:29 sshd[93842]: pam_unix(sshd:auth): check pass; user unknown
Jan 11 00:19:29 sshd[93842]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ru>
Jan 11 00:19:31 sshd[93842]: Failed password for invalid user admin from 41.207.248.204 port 37194 ssh2
Jan 11 00:19:32 sshd[93842]: Connection closed by invalid user admin 41.207.248.204 port 37194 [preauth]
Jan 11 00:20:14 sshd[93886]: Invalid user svn from 84.108.40.27 port 44968
Jan 11 00:20:14 sshd[93886]: pam_unix(sshd:auth): check pass; user unknown
Jan 11 00:20:14 sshd[93886]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ru>
Jan 11 00:20:16 sshd[93886]: Failed password for invalid user svn from 84.108.40.27 port 44968 ssh2
Jan 11 00:20:16 sshd[93886]: Received disconnect from 84.108.40.27 port 44968:11: Bye Bye [preauth]
Jan 11 00:20:16 sshd[93886]: Disconnected from invalid user svn 84.108.40.27 port 44968 [preauth]
[...]
Wenn ich nicht wüsste, dass dies das inzwischen übliche “Rauschen” im Internet ist, würde es mich schon etwas beunruhigen. Ist ja fast so, als würde alle paar Sekunden jemand mit bösen Absichten versuchen, irgendeinen Schlüssel an meiner Haustür auszuprobieren. Was kann man dagegen also tun? Wegschicken!
fail2ban
Genau dies soll die Software fail2ban für mich übernehmen.
__ _ _ ___ _
/ _|__ _(_) |_ ) |__ __ _ _ _
| _/ _` | | |/ /| '_ \/ _` | ' \
|_| \__,_|_|_/___|_.__/\__,_|_||_|
v1.1.0.dev1 20??/??/??
Vereinfacht gesagt durchforstet fail2ban
Zugriffslogs1 nach IP-Adressen, für die fehlgeschlagene Anmeldeversuche registriert wurden, und “bannt” sie bei Überschreiten einer benutzerdefinierten Anzahl Versuche innerhalb einer bestimmten Zeit für einen gewünschten Zeitraum.
Wie dieser Bannspruch umgesetzt wird? Fail2ban modifiziert die iptables, greift also auf Paketfilterregeln zu (Stichwort Firewall), welche sich unten auf der Netzwerkschicht befinden. So werden hereinkommende Anfragen bereits geblockter Adressen gar nicht erst bis zu meinen Applikationen durchkommen2.
fail2ban installieren
fail2ban
scheint so etwas wie Industriestandard bei der Abwehr unerwünschter Zugriffsversuche auf Linux zu sein. Jedem Hobby- und Profiadmin den ich kenne war das Programm geläufig. Ich erntete für meine Unwissenheit des Öfteren ein müdes Lächeln.
Zu Installation und Konfiguration gibt es bereits einen Haufen Anleitungen da draußen plus der (sehr gut geschriebenen), die im Repo von Fail2ban gleich mitgeliefert wird. Daher gehe ich nicht besonders tief hierauf ein.
Ich entschied mich für die Installation im Docker-Container, damit ich die üblichen Abhängigkeiten gleich mitgeliefert bekomme. Dafür verwende ich die open-source Distribution von linuxserver und verfasse die folgende docker-compose.yml
:
# /fail2ban/docker-compose.yml
version: "2.1"
services:
fail2ban:
image: lscr.io/linuxserver/fail2ban:latest
container_name: fail2ban
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- VERBOSITY=-vv #optional
volumes:
- ./config:/config
- /var/log/auth.log:/var/log/auth.log:ro # host ssh
- /var/log/caddy2:/var/log/caddy2:ro # gitea via caddy, caddyserver
restart: unless-stopped
Das Einzige, was hier zu beachten gilt: fail2ban
benötigt die Access logs per Volume zur Verfügung gestellt (oben als :ro
nur mit Lesezugriff angegeben). Eine Menge Filterregeln liegen bereits vorkonfiguriert im config
-Ordner, daher muss ich nur die jail.local
nach linuxserver/fail2ban-confs
sowie filter.d
für Caddy nach muetsch.io anpassen und schon habe ich einen automatischen Türwächter.
fail2ban Beispiel
So sieht ein fail2ban-Log nun für meinen SSH-Daemon aus:
# schallbert:/opt/fail2ban/config/log/fail2ban# grep "220.124.89.47" fail2ban.log
2024-01-11 17:12:20,600 7FBB3630BB38 INFO [sshd] Found 220.124.89.47 - 2024-01-11 17:12:20
2024-01-11 17:12:23,003 7FBB3630BB38 INFO [sshd] Found 220.124.89.47 - 2024-01-11 17:12:22
2024-01-11 17:12:25,205 7FBB3630BB38 INFO [sshd] Found 220.124.89.47 - 2024-01-11 17:12:24
2024-01-11 17:12:27,206 7FBB3630BB38 INFO [sshd] Found 220.124.89.47 - 2024-01-11 17:12:26
2024-01-11 17:12:32,610 7FBB3630BB38 INFO [sshd] Found 220.124.89.47 - 2024-01-11 17:12:32
2024-01-11 17:12:33,045 7FBB36104B38 NOTIC [sshd] Ban 220.124.89.47
Und tschüss!
Was (noch) nicht funktioniert: Gitea & fail2ban
Ich bekomme auch auf meiner Gitea-Instanz ssh-Anfragen rein, die ich ebenfalls gern wegblocken möchte. Allerdings kann ich Gitea bis jetzt partout nicht dazu bekommen, die Logs dafür auch in eine Datei zu schreiben. Bis jetzt werden die stets an die Konsole geschickt, wo ich sie fail2ban natürlich nicht zuführen kann und möchte. Tatsächlich wird die Verbindung zu Gitea per SSH zum Glück bereits gesichert, da sie über denselben sshd
geht wie der Shell-Zugriff auf meinen Server selbst.
Was mir noch fehlt sind nur die Überwachung fehlgeschlagener Logins auf dem Web-Frontend.
Dabei sieht Giteas app.ini
für mich sauber aus:
# gitea/conf/app.ini
#[...]
[log]
MODE = file
LEVEL = warn
ROOT_PATH = /data/gitea/log
ENABLE_ACCESS_LOGS = true
ENABLE_SSH_LOG = true
logger.access.MODE = access-file
[log.access-file]
MODE = file
ACCESS = file
LEVEL = info
FILE_NAME = access.log
[...]
Ich habe sowohl die Access-Logs aktiviert ENABLE_ACCESS_LOGS
und den Access-Logger in eine Datei schreiben lassen. Die Logs werden auch erstellt, nur sind da keine Zugriffe drin aufgelistet - diese gehen nach wie vor in die Konsole des Containers. Ich bin mir aber noch unsicher, ob der Reverse-Proxy von Caddy hier vielleicht einen Einfluss hat und er zum Beispiel die Zugriffe vor Gitea bereits abfängt. Aber das finde ich schon irgendwann noch heraus.
Update Aug-2024
Irgendwann fragte ich mich, ob fail2ban auch tatsächlich auf den iptables meines Servers arbeitet oder nur innerhalb des Containers korrekt funktioniert. Zum Glück war ich nicht der Erste mit dieser Frage (stackoverflow) und fand die vorgeschlagene Lösung ganz charmant:
- im Docker-Container eine beliebige IP sperren (
docker exec -it fail2ban sh
):fail2ban-client set sshd banip 111.111.111.111
- In den iptables nachsehen, ob diese IP dort auftaucht:
iptables -n -L --line-numbers
- In meinem Falle: freuen, denn sie ist da:
1 REJECT all -- 111.111.111.111
- Die IP wieder entsperren
fail2ban-client set sshd unbanip 111.111.111.111
Regelmäßige Backups
Ein wichtiger Aspekt für mich ist die Möglichkeit, den Server wiederherstellen zu können. Sollten unvorhergesehene Ereignisse eintreten wie ein Update einer Komponente welches die Funktion anderer Programme beeinträchtigt, der Ausfall des Servers, oder sogar der Zugriff fremder Personen auf selbigen - in jedem Falle könnte ich mit einem Backup recht schnell eine neue Instanz des Servers erzeugen, konfigurieren und die Website wieder zum Laufen bringen.
Diese Updates möchte ich aber ungern selbst von Hand aus meinen Ordnern erzeugen, komprimieren und per SCP / SFTP
herunterladen. Besser soll das vollautomatisch vonstatten gehen, und das im Optimalfall noch kostenfrei. Nach einer kurzen Recherche stellen sich für meine Zwecke die quelloffenen Werkzeuge borgmatic und restic als geeignet heraus.
Ich entscheide mich willkürlich für Borgmatic.
Borgmatic in Docker installieren
Wie bei allen anderen Komponenten auch, möchte ich Borgmatic in einem Container laufen lassen. Glücklicherweise gibt es da bereits eine fertige Lösung, die ich nur noch ein wenig konfigurieren muss.
Mein docker-compose
File für Borgmatic sieht wie folgt aus:
# /borgmatic/docker-compose.yml
version: '3'
services:
borgmatic:
image: ghcr.io/borgmatic-collective/borgmatic
container_name: borgmatic
volumes:
- ${VOLUME_SOURCE}:/mnt/source:ro # backup source
- ${VOLUME_TARGET}:/mnt/repository # backup target
- ${VOLUME_ETC_BORGMATIC}:/etc/borgmatic.d/ # borgmatic config file(s) + crontab.txt
- ${VOLUME_BORG_CONFIG}:/root/.config/borg # config and keyfiles
- ${VOLUME_SSH}:/root/.ssh # ssh key for remote repositories
- ${VOLUME_BORG_CACHE}:/root/.cache/borg # checksums used for deduplication
# - /var/run/docker.sock:/var/run/docker.sock # add docker sock so borgmatic can start/stop containers to be backupped
environment:
- TZ=${TZ}
- BORG_PASSPHRASE=${BORG_PASSPHRASE}
restart: always
Als Inspiration hierfür habe ich reichlich in der Dokumentation auf Github gestöbert.
Borgmatic konfigurieren
Alle konkreten Daten (die Angaben in ${}
) habe ich zur besseren Übersichtlichkeit in einer .env
-Datei im selben Verzeichnis abgelegt. Ganz nebenbei verhindere ich, aus Versehen Passphrases zu veröffentlichen. Diese Passphrase habe ich mir zusätzlich auf einem Zettel notiert - man weiß ja nie, ob man sie nochmal benötigt.
Ich muss anschließend nur noch die sich in dem Ordner borgmatic.d/
befindliche config.yml
leicht verändern, indem ich Backup-Quelle, -Ziel und die von mir gewünschten Zeitintervalle für die Sicherungserstellung eintrug und war quasi schon bereit für einen ersten Test.
Borgmatic Funktionstest
docker exec borgmatic bash -c \
"cd && borgmatic --stats -v 1 --files 2>&1"
Mit diesem Befehl führe ich über Docker im Container borgmatic
den Befehl zum Anlegen einer Sicherung an, um die Funktion von Borgmatic zu verifizieren.
Hier kam dann direkt eine Fehlermeldung à la repository does not exist
zurück. Also existiert das Backup-Ziel noch gar nicht. Ein kurzer Blick in die Dokumentation zeigt, dass das Repository erst initialisiert werden muss.
Dies hole ich mit dem Kommando
docker exec borgmatic bash -c \
"borgmatic init --encryption repokey-blake2"
nach, was mit ein “leeres”, verschlüsseltes Repository anlegt. Versuche ich erneut, ein Backup zu erzeugen, läuft Borgmatic nun durch und erzeugt mir das Backup im Repository.
Ein Backup ist nur ein Backup…
…wenn man es einspielen kann, sagt mein Kumpel.
Er hat Recht, aber so richtig traue ich mich nicht, über meine funktionierende Serverkonfiguration drüberzubügeln. Also erstelle ich mir eine docker-compose.restore.yml
Datei nach Vorbild auf dem Repository und ziehe modem7s Anleitung zum Thema hinzu.
Tatsächlich kann ich durch Ausführen der docker-compose.restore.yml
dann in der Container-Shell folgende Kommandos ausführen:
mkdir backuprestoremount
borg mount /mnt/repository /backuprestoremount
mkdir backuprestore
borgmatic extract --archive latest --destination /backuprestore
Hier erstelle ich den Ordner backuprestoremount
und lasse ihn von borg
auf mein Backup zeigen. Anschließend extrahiere ich das Backup in den ebenfalls neu erstellten Ordner backuprestore
.
Nun prüfe ich, ob auch “alles da ist”:
# cd /mnt/backuprestore
# /restore/mnt/source ls
borgmatic caddy2 containerd fail2ban gitea hostedtoolcache watchtower
Yeah, alle Applikationen sind vorhanden und die enthaltenen Daten sind gesichert! Weiter gehe ich jetzt mal nicht, denn ich bin zu feige (und zu faul), die auf dem Server unter /opt
befindlichen Container zu überschreiben.
Was (noch) nicht funktioniert: Borgmatic & docker-compose down
Borgmatic bietet eine einfache Möglichkeit in der Konfigurationsdatei, Aktionen vor- und nach dem Backup auszuführen.
Da ich die Konsistenz der Datenbanken meiner zu sichernden Container sicherstellen möchte, sollten die Container zum Zeitpunkt des Backups heruntergefahren sein. Daher schrieb ich mir ein Skript, was alle Applikationen vor dem Backup per docker compose down
stoppt und nach dem Backup wieder hochfährt.
Dies würde auch alles prima funktionieren, wenn Borgmatic nicht selbst in einem Container liefe. Durch die Kapselung bin ich nun nicht in der Lage, die im Stamm-Dateisystem der Maschine befindlichen Skripte auszuführen. Kurzzeitig dachte ich, dass ich dies mittels Durchreichung von var/run/docker.sock
in den Borgmatic-Container umgehen könne, scheiterte dann aber an der Ankopplung an Docker compose.
Ich bin mir sicher, dass das zu lösen ist. Allerdings habe ich gerade dringendere Themen. Daher begnüge ich mich mit der Annahme, dass schon nichts passiert, wenn ich das Backup nachts ziehe3.
Auch bei den Auto-Backups scheint es noch zu haken:
/ # borgmatic --stats
Starting a backup job.
Failed to create/acquire the lock /mnt/repository/lock (timeout).
local: Error running actions for repository
Command 'borg create --stats /mnt/repository::{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f} /etc/borgmatic.d/config.yml /mnt/source /root/.borgmatic' returned non-zero exit status 2.
Error while creating a backup.
/etc/borgmatic.d/config.yml: An error occurred
Dies entnehme ich der über Docker aufgerufenen Log-Konsole von Borgmatic. Ich muss also beizeiten nochmal um meine Backups kümmern.
-
Beim SSH-Daemon meines Servers liegen die Zugriffslogs z.B. in
/var/log/auth.log
, können aber auch alsaccess.log
oder ähnlich abgelegt werden. Auch von mir verwendete Dienste wie Gitea und Caddy legen solche Logdateien an. ↩ -
Was ich allerdings noch nicht verstehe ist, wie
fail2ban
im Container laufend überhaupt an die iptables herankommt. Ich dachte, dass ein positiver Nebeneffekt der Containerisierung in der Kapselung liegt. Oder ist sie nur bei “rootless”-Containern zu erreichen? ↩ -
Hintergrund: Ich bin zur Zeit der Einzige, der Inhalte auf
gitea
hochlädt. Meine Webseite ist statisch. Auf dem Webservercaddy
ändern sich also nur dann Dateien, wenn ich sie über Gitea hochlade. Programmupdates überwatchtower
undunattended-upgrades
(Thema eines weiteren Artikels) habe ich so gelegt, dass sie nicht während des Durchlaufs vonborgmatic
stattfindet. Einzig die Logs, welche beifail2ban
aufschlagen, werden auch zur Zeit der Erstellung des Backups geschrieben. Das Risiko des Datenverlustes gehe ich an der Stelle bewusst aber ein, da die Paketfilterung für geblockte IP-Adressen durch fail2ban nach einer von mir bestimmten Zeit sowieso wieder aufgehoben wird. ↩