Was zählt wirklich
Die Server-Basis und die Tailscale-Anleitung erledigen schon das Meiste (Key-only SSH, Cloud-Firewall, kein offener Port). Diese Anleitung ist für den Fall, dass der Server doch öffentlich sein muss (Web auf 80/443).
| Prio | Maßnahme | Aufwand |
|---|---|---|
| 1 | Angriffsfläche klein: nur 22 (auf IP/Tailnet) + 80/443 offen, sonst nichts | klein |
| 2 | Automatische Sicherheitsupdates + Auto-Reboot nachts | klein |
| 3 | SSH: kein Root, keine Passwörter, AllowUsers, moderne Krypto | klein |
| 4 | CrowdSec (blockt Scanner & Brute-Force automatisch) | mittel |
| 5 | nftables am Server (Verteidigung in der Tiefe) – Achtung Docker | mittel |
| 6 | sysctl-Hardening, Docker-daemon.json, auditd | mittel |
| – | Die Anwendungen aktuell halten und app-seitige Auth (starke Passwörter, 2FA) – hier passieren die meisten echten Einbrüche | laufend |
1 SSH härten
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
# moderne Krypto (Mozilla "modern") -- ssh-audit prueft das
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
sshd -t && systemctl reload ssh
# nur schwache Host-Keys entfernen (ed25519 + rsa behalten)
rm -f /etc/ssh/ssh_host_ecdsa_key* /etc/ssh/ssh_host_dsa_key*
systemctl restart ssh
In einer zweiten SSH-Sitzung neu einloggen und prüfen, bevor du die erste schließt. Rettung sonst nur über die Hetzner-Konsole (Rescue/VNC).
- Port ändern bringt wenig gegen gezielte Angriffe, aber viel Ruhe in
den Logs. Wenn, dann in
99-hardening.confund in der Firewall. - 2FA für SSH: am elegantesten über
Tailscale-SSH im Check-Modus
(erzwingt Browser-Re-Auth). Sonst
libpam-google-authenticator.
2 Automatische Updates
apt-get install -y unattended-upgrades needrestart apt-listchanges
dpkg-reconfigure -plow unattended-upgrades # "Yes"
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:12";
Unattended-Upgrade::MailReport "on-change";
Unattended-Upgrade::Mail "du@example.com"; # braucht SMTP-Relay -- oder weglassen und per Log/Monitoring pruefen
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
- Debian zusätzlich
${distro_id}:${distro_codename}-updatesinAllowed-Originsaufnehmen. needrestartstartet nach Bibliotheks-Updates die betroffenen Dienste neu (NEEDRESTART_MODE=ain/etc/needrestart/needrestart.conffür unbeaufsichtigt).- Kernel-Live-Patching (Ubuntu Livepatch, Debian nicht offiziell) spart Reboots – für einen Hobby-Server ist der 04:12-Reboot ok.
- Überwachen: der Netdata-Alarm „Reboot nötig“ bzw. „Pakete veraltet“.
3 nftables & die Docker-Falle
Hinter der Hetzner-Cloud-Firewall – als zweite Schicht, und weil die Cloud-Firewall keine Regeln zwischen Prozessen kennt.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
tcp dport 22 accept
tcp dport { 80, 443 } accept
# Rest wird verworfen (policy drop)
}
}
nft -c -f /etc/nftables.conf && systemctl enable --now nftables
Ein ports: ["8080:8080"] in compose.yaml ist von außen
offen – egal was in nftables input steht. Docker schreibt eigene
DNAT-/FORWARD-Regeln. Drei Auswege:
1. Nie Ports veröffentlichen – alles hinter einen Reverse Proxy
im Docker-Netz (so machen es die cloud-host-Guides). Dann ist nur Traefik/nginx auf 80/443.
2. An localhost binden: 127.0.0.1:8080:8080.
3. In die DOCKER-USER-Kette filtern (nftables:
iifname "eth0" ip saddr != <vertraut> drop in einer
docker-user-Chain hooken).
4 CrowdSec
Wertet Logs aus (SSH, nginx/Traefik), erkennt Brute-Force und Scans, und ein
Bouncer sperrt die IP (Internet Protocol) in nftables. Plus: die
Community-Blocklist bekannter bösartiger IPs (Internet-Protocol-Adressen).
curl -s https://install.crowdsec.net | sh
apt-get install -y crowdsec crowdsec-firewall-bouncer-nftables
cscli collections install crowdsecurity/sshd crowdsecurity/nginx \
crowdsecurity/http-cve crowdsecurity/base-http-scenarios
systemctl reload crowdsec
# optional: kostenloses Dashboard + Community-Blocklist
cscli console enroll <enroll-key>
cscli metrics # was wurde geparst, was ausgeloest
cscli decisions list # aktuell gesperrte IPs
cscli alerts list
fail2ban tut es auch (/etc/fail2ban/jail.local →
[sshd] + [recidive], bantime.increment=true). CrowdSec
ist moderner, kann HTTP-Angriffe und teilt Bedrohungsdaten – für einen Web-Server
die bessere Wahl.
5 Kernel & sysctl
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
sysctl --system
Docker/k8s brauchen net.ipv4.ip_forward = 1 – das setzt Docker selbst, nicht
überschreiben. rp_filter ggf. auf 2 (loose), wenn asymmetrisches
Routing (mehrere Interfaces / Tailscale-Subnet-Router) sonst Pakete verwirft.
6 Nutzer, sudo, Rechte
- Arbeiten als Nicht-Root (
deploy),sudonur wo nötig.NOPASSWDausschließlich für skriptbare Einzelbefehle (/etc/sudoers.d/deploy:deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app). - Ungenutzte Konten sperren:
usermod -L -e 1 <user>. Der cloud-init-Default-User bleibt (Hetzner braucht ihn), aber Passwort-Login ist ja aus. - Dienst-Konten ohne Shell:
usermod -s /usr/sbin/nologin <svc>. - Falls du lokale Nutzer mit Passwort hast: Alterung in
/etc/login.defs(PASS_MAX_DAYS 365), starke Hashes (yescrypt ist Default auf Debian 12). umask 027in/etc/profile.d/,/rootauf700.
7 Docker/Podman härten
{
"no-new-privileges": true,
"icc": false,
"userland-proxy": false,
"live-restore": true,
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
- Im Compose je Service:
security_opt: ["no-new-privileges:true"],cap_drop: ["ALL"](+cap_addnur was nötig),read_only: truemittmpfsfür Schreibpfade. - Nie
--privileged, nie/var/run/docker.sockin einen Container (= Root auf dem Host). Ausnahmen (Traefik, Watchtower) read-only und nur wenn wirklich nötig, besser ein Socket-Proxy. - Images: Tags und Digests pinnen, regelmässig
docker scout cves <image>odertrivy image <image>. - Rootless: Podman rootless (die container-host-Anleitung) oder Docker rootless-Modus – ein Container-Ausbruch landet dann nicht als Root auf dem Host.
docker-bench-securitygibt eine Checkliste mit Punkten.
8 auditd & Integrität
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k root_cmd
-e 2
apt-get install -y auditd
augenrules --load && auditctl -l
ausearch -k sshd -ts today # wer hat sshd_config angefasst
auditd hat eine eigene, sehr knappe Syntax. Sie zerfällt in zwei Sorten:
Watches (-w) auf Dateien und Rules (-a) auf
Systemaufrufe.
| Schalter | Sorte | bewirkt |
|---|---|---|
apt-get install -y auditd |
Server | auditd ist der Protokolldienst des Linux-Kernels – er schreibt nach /var/log/audit/audit.log, unabhängig von syslog und journald. Das -y beantwortet die Rückfrage im Voraus mit Ja, damit der Befehl in einem Skript nicht auf eine Eingabe wartet. |
/etc/audit/rules.d/<name>.rules |
Dateiname | Die Regeln stehen nicht in einer einzigen Datei, sondern in einem Verzeichnis – jede .rules-Datei darin ist ein Baustein, und augenrules setzt sie in alphabetischer Reihenfolge zusammen. Deshalb lässt sich eigenes Regelwerk hinzufügen, ohne das mitgelieferte anzufassen. Die Reihenfolge ist wichtig, weil -e 2 am Ende alles einfriert. |
-w /etc/passwd |
Watch | Überwacht einen Pfad. Ein abschließender Schrägstrich wie bei /etc/sudoers.d/ bedeutet: das ganze Verzeichnis samt neu angelegter Dateien – sonst entkäme genau der Trick, eine zusätzliche Datei danebenzulegen statt die bekannte zu ändern. |
-p wa |
Watch | Welche Zugriffe protokolliert werden: w = schreiben, a = Attribute (Rechte, Eigentümer). Bewusst ohne r – Lesezugriffe auf /etc/passwd passieren ständig und im Sekundentakt, das Log wäre danach unbrauchbar. |
-k identity |
beide | Ein frei wählbares Schlüsselwort, das an jeden Log-Eintrag angehängt wird. Nur dadurch funktioniert ausearch -k sshd weiter unten – ohne -k müsste man das Log nach Pfaden durchsuchen. |
-a always,exit |
Rule | „Immer protokollieren, und zwar beim Verlassen des Systemaufrufs.“ Erst dort steht fest, ob er erfolgreich war – beim Eintritt wäre das noch offen. |
-F arch=b64 |
Rule | Gilt für 64-Bit-Aufrufe. Die Angabe ist Pflicht, weil Systemaufrufe je Architektur andere Nummern tragen. Auf einem System, das auch 32-Bit-Programme ausführen kann, gehört dieselbe Zeile ein zweites Mal mit arch=b32 dazu – sonst bleibt genau dieser Weg unbeobachtet. |
-S execve |
Rule | Der Systemaufruf, mit dem jedes Programm gestartet wird. Diese eine Zeile protokolliert also jeden Befehl, der die folgenden Bedingungen erfüllt – samt Argumenten. |
-F euid=0 |
Rule | Nur Aufrufe, die effektiv als root laufen. Das trifft auch alles hinter sudo, denn dort ist die effektive Kennung bereits 0, während die ursprüngliche erhalten bleibt. |
-F auid>=1000-F auid!=-1 |
Rule | Die Audit-UID ist die Kennung, mit der sich jemand ursprünglich angemeldet hat; sie bleibt über sudo und su hinweg unverändert. >=1000 filtert auf echte Menschen (Systemkonten liegen darunter), !=-1 wirft die Prozesse ohne Anmeldung heraus – also die Dienste, die beim Systemstart hochkommen. Zusammen bedeutet die Zeile: protokolliere jeden Befehl, den ein angemeldeter Mensch als root ausführt, und ignoriere das Grundrauschen des Systems. In manchen älteren Beispielen steht dafür auid!=4294967295 – dieselbe Zahl, nur unsigniert geschrieben. |
-e 2 |
Abschluss | Friert das Regelwerk ein: bis zum nächsten Neustart lässt sich keine Regel mehr ändern oder löschen – auch nicht von root. Das ist der Punkt der ganzen Übung, denn ein Angreifer mit Root-Rechten würde sonst zuerst die Protokollierung abschalten. Der Preis: jede eigene Änderung an den Regeln braucht ebenfalls einen Neustart. Beim Einrichten deshalb zuletzt hinzufügen; -e 1 lässt das Regelwerk offen. |
augenrules --load |
Server | Setzt alle Dateien aus /etc/audit/rules.d/ zusammen und lädt sie. Der direkte Weg auditctl -R würde diese eine Datei laden und die übrigen übergehen. auditctl -l danach zeigt, was tatsächlich aktiv ist – nach -e 2 die einzige Kontrolle, die bleibt. |
ausearch -k sshd -ts today |
Server | Sucht über das Schlüsselwort statt über Pfade. -ts („time start“) versteht neben today auch recent, this-week oder einen Zeitpunkt – ohne die Angabe durchsucht es das gesamte Log. |
- Datei-Integrität:
debsums -c(leicht, prüft Paketdateien) – oderAIDE(gründlich, aber Pflege: nach jedem Updateaide --update). - Die audit-Logs in den Monitoring-Kanal
(Netdata liest
/var/log/audit/audit.log), oder wöchentlich per Cronaureport --auth --summaryansehen.
9 Prüfen: Lynis, ssh-audit
apt-get install -y lynis
lynis audit system # "Hardening index" 0-100, Ziel ~70+, plus konkrete Vorschlaege
ssh-audit dienste # SSH-Krypto bewerten (pip install ssh-audit oder docker)
nmap -Pn -p- dienste # von aussen -> nur 22/80/443 (oder nur 80/443 mit Tailscale)
- Web: internet.nl (TLS, DNSSEC, Header, Mail), securityheaders.com, für Nextcloud scan.nextcloud.com.
- Wiederholen: nach jedem grösseren Update Lynis erneut, den Index
beobachten. Ein-, zweimal im Jahr
nmapvon außen.
+ Was Übertreibung ist
Für einen 1–3-Personen-Server holen diese Dinge fast nichts mehr raus, kosten aber viel Pflege – erst machen, wenn ein konkreter Grund da ist:
- Eigene AppArmor/SELinux (Security-Enhanced Linux)-Profile schreiben (die Distro-Defaults anlassen reicht).
- Der volle CIS-Benchmark Punkt für Punkt.
- Ein HIDS/IDS außer CrowdSec (Wazuh, OSSEC, Suricata) – Rauschen, Pflege, kaum Mehrwert bei einem Server.
- Strenges Egress-Filtering (raus-Regeln in nftables) – bricht ständig Updates/Container und schützt wenig.
- Verschlüsselte Root-Partition – auf einem Cloud-Server ohne physischen Zugriff wenig sinnvoll, Hetzner sieht die Platte ohnehin nicht (aber Backups verschlüsseln – ja!).
Key-only SSH · wenig offene Ports · automatische Updates · CrowdSec · app-seitige Auth stark und die Anwendungen aktuell. Der Rest ist Feinschliff.
+ Hardening ab cloud-init
Alles Obige einmal in die Basis-cloud-init, dann startet jeder neue Server gehardened.
packages: [ unattended-upgrades, needrestart, nftables, auditd, lynis ]
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
- path: /etc/sysctl.d/99-hardening.conf
content: |
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_regular = 2
- path: /etc/nftables.conf
content: |
#!/usr/sbin/nft -f
flush ruleset
table inet filter { chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept ; iif lo accept ; ct state invalid drop
ip protocol icmp accept ; ip6 nexthdr ipv6-icmp accept
tcp dport { 22, 80, 443 } accept
} }
runcmd:
- systemctl reload ssh
- sysctl --system
- systemctl enable --now nftables
- curl -s https://install.crowdsec.net | sh
- apt-get install -y crowdsec crowdsec-firewall-bouncer-nftables
Danach einmal lynis audit system
und einen Snapshot – ab dem up bootet jeder Server so.