Den Server absichern

9 Minute(n) Lesezeit

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.

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.

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.

  1. Beim SSH-Daemon meines Servers liegen die Zugriffslogs z.B. in /var/log/auth.log, können aber auch als access.log oder ähnlich abgelegt werden. Auch von mir verwendete Dienste wie Gitea und Caddy legen solche Logdateien an. 

  2. 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? 

  3. Hintergrund: Ich bin zur Zeit der Einzige, der Inhalte auf gitea hochlädt. Meine Webseite ist statisch. Auf dem Webserver caddy ändern sich also nur dann Dateien, wenn ich sie über Gitea hochlade. Programmupdates über watchtower und unattended-upgrades (Thema eines weiteren Artikels) habe ich so gelegt, dass sie nicht während des Durchlaufs von borgmatic stattfindet. Einzig die Logs, welche bei fail2ban 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.