Server- und Netzwerkinfrastruktur mit NixOS (1/3)¶
Seit 2019 beschäftige ich mich mit der Administration Linux-basierter Serversysteme. Angefangen hat alles mit eigenen Projekten. Mit der Zeit übernahm ich jedoch auch die Betreuung von Systemen für Freunde und Bekannte, darunter beispielsweise für den YouTuber The Morpheus Tutorials.
Um das Einarbeiten weiterer Teammitglieder und die spätere Übergabe von Aufgaben zu vereinfachen, begann ich Ende 2020 mit der Erstellung des sogenannten AdminGuide's. Dabei handelte es sich um eine Sammlung aus Konzepten, Beispielkonfigurationen und "Lessons Learned", die ich bei der Administration der betreuten Systeme angewandt und dokumentiert hatte.
Mit der Zeit stellte ich fest, dass ich bestimmte Komponenten (z. B. Monitoring oder Log-Collection) in verschiedenen Infrastrukturen mehrfach aufbaute. Um diese Aufgaben zu vereinheitlichen und wiederverwendbar zu machen, begann ich daher mit der Erstellung eigener Ansible-Rollen.
Ende 2022 beschäftigte ich mich intensiver mit NixOS und entschied mich, einige Komponenten testweise damit neu zu deployen. Von diesem Konzept war ich direkt überzeugt. Innerhalb kurzer Zeit migrierte ich schließlich eine vollständig von mir betreute Infrastruktur auf NixOS.
Im Gegensatz zu traditionellen Automatisierungswerkzeugen wie Ansible oder Salt, die überwiegend imperativ arbeiten, setzt NixOS auf eine deklarative Konfiguration. Dabei wird nicht Schritt für Schritt beschrieben, welche Änderungen auf einem System vorgenommen werden sollen, sondern welcher Zielzustand erreicht werden soll. Mit einem sogenannten rebuild wird diese Konfiguration anschließend angewandt und das System in den definierten Zustand versetzt.
Für Server- und Netzwerkinfrastruktur eignet sich NixOS besonders gut, da es die Möglichkeit bietet die Konfiguraion versioniert und nachvollziehbar zu verwalten. Die Konfigurationsdateien können in Git abgelegt werden, was die Zusammenarbeit stark vereinfacht. Änderungen lassen sich dadurch dokumentieren und vor der Anwendung beispielsweise über Pull Requests prüfen und reviewen. Gleichzeitig können mehrere Systeme konsistent konfiguriert werden, wodurch Konfigurationsabweichungen und manuelle Fehler reduziert werden.
Ein weiterer Vorteil ist, dass Systeme auf Grundlage derselben Konfiguration für verschiedene Anwendungsfälle (z. B. Test-, Staging- und Produktionsumgebungen) aufgebaut werden können und dadurch Reproduzierbar sind. Dies wiederum vereinfacht die Wartung, Migration und Wiederherstellung. Durch die Verwaltung verschiedener Systemgenerationen ist es außerdem möglich, bei fehlerhaften Änderungen auf eine zuvor funktionierende Konfiguration zurückzurollen.
Für größere Infrastrukturen ist es besonders vorteilhaft, Konfigurationen zentral zu definieren und von den einzelnen Systemen darauf verweisen zu lassen. Dadurch entsteht eine zentrale Single Source of Truth.
NixOS bringt jedoch eine deutlich steilere Lernkurve mit sich, da sich sowohl die Nix-Sprache als auch das deklarative Modell grundlegend von der klassischen Administration anderer Linux-Distributionen unterscheiden.
Nix¶
Syntax¶
In diesem Kapitel werden einige Eigenschaften der Nix Syntax erläutert. Es dient primär als Nachschlagewerk für die später genutzten Konfigurationen.
# Einzeiliger Kommentar
/*
Mehrzeiliger Kommentar
*/
{
# Datentypen
enable = false;
port = 8000;
domain = "hedgedoc" + " concat";
message = ''
Dieser Text kann
über mehrere Zeilen gehen.
'';
# String-Interpolation
fqdn = "${hostname}.example.com";
ignore = ''
keine ''${String-Interpolation}
in dieser Variable.
'';
# Listen
webPorts = [ 80 443 ]; # kein Komma!
sshPorts = [ 22 ];
allPorts = webPorts ++ sshPorts;
# Attrs
server = {
hostname = "server01";
role = "webserver";
sshPort = 22;
};
client = {
name = "nico";
};
both = server // client;
}
{
a.b.c = true;
# ist das gleiche wie
a = {
b = {
c = true;
};
};
# wenn punkte in key vorkommen, dann muss gequoted werden:
a."b.c" = true;
# ist das gleiche wie
a = {
"b.c" = true;
};
}
Package und Option Search¶
Bei der Konfiguration eines NixOS-Systems ist es häufig notwendig, zunächst den korrekten Namen eines Pakets oder einer Konfigurationsoption zu ermitteln. Dafür stellt das Nix-Ökosystem eine Websuche zur Verfügung.
Formatting¶
Ein einheitlicher Stil macht Nix-Code leichter lesbar und einfacher zu warten. Dafür
eignen sich zwei Werkzeuge, die unterschiedliche Aufgaben übernehmen: nixfmt formatiert
den Code; deadnix sucht nach ungenutzten Bindings, also etwa Variablen oder
Funktionsargumenten, die im Ausdruck nicht verwendet werden.
Installation¶
Für die ersten Schritte mit NixOS auf einem Server empfiehlt sich die Installation über das offizielle Minimal-ISO-Image, das auf der NixOS-Website zu finden ist.
Partitionierung¶
Nachdem NixOS gestartet wurde, muss die Festplatte, auf der es installiert werden soll, partitioniert werden. github:nix-community/disko ermöglicht die Konfiguration von Partitionierung und Formatierung deklarativ in Nix. Diverse Konfigurationsbeispiele, wie die Verwendung von LUKS oder ZFS, können den examples im disko Repository entnommen werden.
Um die ersten Schritte übersichtlich zu halten, werden zunächst lediglich eine EFI-Systempartition sowie ein unverschlüsseltes ext4-root-Dateisystem eingerichtet. In späteren Kapiteln werden neben der Verwendung von LUKS, LVM und ZFS auch verschiedene Möglichkeiten zum Entsperren verschlüsselter Systeme behandelt, darunter die TPM-Integration mittels systemd-cryptenroll sowie das Remote-Entsperren per SSH aus der Initrd-Umgebung.
# /tmp/disk-config.nix
{
disko.devices.disk.disk1 = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
esp = {
name = "boot";
size = "511M";
type = "EF00"; # EFI system partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
name = "root";
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
sudo nix --experimental-features "nix-command flakes" run \
github:nix-community/disko/latest \
-- --mode destroy,format,mount /tmp/disk-config.nix
Alternativ besteht natürlich die Möglichkeit das System manuell zu partitionieren:
sudo parted /dev/sda -- mklabel gpt
sudo parted /dev/sda -- mkpart ESP fat32 2MB 512MB
sudo parted /dev/sda -- set 1 esp on
sudo parted /dev/sda -- mkpart primary 512MB 100%
sudo mkfs.fat -F 32 -n boot /dev/sda1
sudo mkfs.ext4 /dev/sda2
sudo mount /dev/sda2 /mnt
sudo mkdir /mnt/boot
sudo mount /dev/sda1 /mnt/boot
Minimalinstallation¶
Anschließend werden mit folgendem Befehl zwei Konfigurationsdateien generiert, welche im weiteren Verlauf genutzt werden:
sudo nixos-generate-config --root /mnt
Falls das System mit Disko aufgesetzt wurde, müssen die fileSystems und swapDevices Optionen
aus der generierte hardware-configuration.nix entfernt werden. Stattdessen wird die disk-config.nix
hinzugefügt.
sudo mv /tmp/disk-config.nix /mnt/etc/nixos/disk-config.nix
# hardware-configuration.nix
{ lib, modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
];
boot.initrd.availableKernelModules = [
"ata_piix"
"uhci_hcd"
"virtio_pci"
"virtio_scsi"
"sd_mod"
"sr_mod"
];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
Die Erzeugte configuration.nix enthält beispielsweise die Konfiguration des Bootloaders,
welche dafür sorgt, dass das System nach der Installation gestartet werden kann. Des Weiteren
wird system.stateVersion konfiguriert, welches die nixpkgs Version mit der das System
installiert wurde enthält, um das Verhalten bestimmter systembezogener Dienste und
Standardwerte bei späteren Aktualisierungen kompatibel zu halten.
Sie lässt sich im groben und ganzen auf den folgenden Inhalt reduzieren:
# configuration.nix
{
boot.loader = {
systemd-boot.enable = true;
efi.canTouchEfiVariables = true;
};
console.keyMap = "de";
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
};
system.stateVersion = "26.05";
}
Für die Verwaltung des Systems nutzen wir die modernen Nix Flakes, anstelle von Nix Channels.
In der Datei flake.nix erstellen wir ein System unter dem Namen server, in welchem wir die
generierten Konfigurationsdateien sowie die disk-config.nix laden.
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
disko.url = "github:nix-community/disko";
};
outputs = { nixpkgs, disko, ... }: {
nixosConfigurations = {
server = nixpkgs.lib.nixosSystem {
modules = [
./configuration.nix
./hardware-configuration.nix
disko.nixosModules.disko
./disk-config.nix
];
};
};
};
}
Zuletzt kann das System installiert werden:
sudo nixos-install --root /mnt/ --flake /mnt/etc/nixos/#server
Dabei wird die Datei flake.lock erzeugt, welche die konkret verwendeten Versionen festhält.
Mit dem Befehl nix flake update lassen sich die darin referenzierten Pakete auf die jeweils
aktuellsten Versionen aktualisieren.
Abschließend wird das System mit dem Befehl reboot neugestartet.
Netzwerkimplementierung¶
NixOS bietet verschiedene Möglichkeiten das Netzwerk zu konfigurieren:
- die klassischen
networking.*-Optionen NetworkManagersystemd-networkdifstate
Die Wahl der Netzwerkimplementierung hängt letztlich von den individuellen Anforderungen und
Präferenzen ab. Obwohl in der NixOS-Netzwerk-Community von den klassischen networking.*-Optionen
abgeraten wird, stellt sie für den Einstieg dennoch eine praktikable Lösung dar.
Für eine konsistente Systemlandschaft kann es jedoch sinnvoll sein, auf allen Systemen dieselbe Implementierung einzusetzen. Dadurch bleibt die Konfiguration einheitlich und leichter nachvollziehbar. Aufgrund der hohen Flexibilität von IfState und der Fokus dieses Artikels auf Server- und Netzwerkinfrasturkur (also Router) wird daher IfState verwendet.
Folgender Codeblock enthält eine statische Dual-Stack (IPv4 + IPv6) Konfiguration. Die MAC- sowie IP-Adresse und Routen müssen entsprechend angepasst werden.
# networking.nix
{
networking = {
ifstate = {
enable = true;
settings = {
interfaces.ens18 = {
addresses = [
"192.168.0.100/24" # <----
"fd08:ef47:ab81:f69d:1034:56ff:fe78:9abc/64" # <----
];
link = {
state = "up";
kind = "physical";
};
identify.perm_address = "12:34:56:78:9a:bc"; # <----
};
routing.routes = [
{
to = "0.0.0.0/0";
dev = "ens18";
via = "192.168.0.1"; # <----
}
{
to = "::/0";
dev = "ens18";
via = "fe80::1"; # <----
}
];
};
};
nameservers = [ "1.1.1.1" ];
};
}
Anschließend wird die networking.nix in der flake.nix eingebunden:
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
disko.url = "github:nix-community/disko";
};
outputs = { nixpkgs, disko, ... }: {
nixosConfigurations = {
server = nixpkgs.lib.nixosSystem {
modules = [
./configuration.nix
./hardware-configuration.nix
disko.nixosModules.disko
./disk-config.nix
./networking.nix # <----
];
};
};
};
}
Um die Konfiguration neu zu bauen und anzuwenden wird schlussendlich der Befehl
sudo nixos-rebuild switch --flake /etc/nixos#server ausgeführt.
Dienste betreiben¶
Nachdem NixOS installiert und die grundlegende Systemkonfiguration eingerichtet ist, widmen wir uns nun dem eigentlichen Zweck des Servers: der Bereitstellung von Diensten. Als ersten Dienst betreiben wir HedgeDoc, eine webbasierte Anwendung zur gemeinsamen Bearbeitung von Markdown-Dokumenten.
Dabei erweitern wir die Konfiguration schrittweise: von einer minimalen Einrichtung bis hin zu einer öffentlich erreichbaren Instanz. Nach jedem Schritt aktivieren wir die Konfiguration und überprüfen, welche Änderungen NixOS tatsächlich vorgenommen hat.
Strukturierung
Die in den folgenden Kapiteln dargestellten Codeblöcke enthalten zur besseren Nachvollziehbarkeit sämtliche angepassten Dienstkonfigurationen. Grundsätzlich empfiehlt es sich, für jeden Dienst eine separate Datei anzulegen, um die Konfiguration übersichtlich zu halten.
Mehr Informationen können dem Kapitel Strukturierung der NixOS Konfiguration im zweiten Teil entnommen werden.
Minimalkonfiguration¶
Das NixOS-Modul für HedgeDoc lässt sich zunächst mit nur einer Option aktivieren.
Grundsätzlich ist es egal, in welcher Datei die Konfiguration definiert wird,
solange diese im System (in der flake.nix) importiert wird. Im Folgenden wird
die Konfiguration in der bislang leeren Datei hedgedoc.nix vorgenommen.
{
services.hedgedoc.enable = true;
}
sudo nixos-rebuild switch --flake /etc/nixos#server
übernommen wurden, ist feststellbar, dass der systemd-Service hedgedoc läuft und
HedgeDoc auf der IPv6-Loopback-Adresse ([::1]) auf Port 3000 zur Verfügung steht
(siehe systemctl status hedgedoc sowie ss -tlpn).
HedgeDoc direkt erreichbar machen¶
Eine Möglichkeit um den Zugriff von anderen Computern auf HedgeDoc zu ermöglichen, wäre die Anpassung der "Listen Address" auf allen Netzwerkadressen:
{
services.hedgedoc = {
enable = true;
settings.host = "0.0.0.0"; # <----
};
}
http://192.168.0.100:3000 erreichbar.
Für einen produktiven Betrieb ist diese Variante allerdings nicht ideal. Der interne Webserver der Anwendung wird dadurch direkt dem Netzwerk beziehungsweise dem Internet ausgesetzt. Außerdem müsste sich die Anwendung selbst um Themen wie TLS-Terminierung (hier: https), HTTP-Weiterleitungen und gegebenenfalls weitere Sicherheits- und Proxy-Einstellungen kümmern.
Stattdessen setzen wir einen Reverse Proxy vor HedgeDoc. Der Reverse Proxy nimmt die externen Verbindungen entgegen und leitet sie intern an HedgeDoc weiter. Dadurch kann der eigentliche Dienst weiterhin nur auf einer lokalen Adresse lauschen. Nginx übernimmt später unter anderem die Entgegennahme der HTTP-Verbindungen und die TLS-Terminierung. Bei HedgeDoc müssen außerdem WebSocket-Verbindungen korrekt weitergereicht werden, da sie beispielsweise für die Echtzeitkommunikation verwendet werden.
nginx als Reverse Proxy konfigurieren¶
Damit der Reverse Proxy sinnvoll konfiguriert werden kann, benötigen wir zunächst
einen DNS-Namen. In diesem Beispiel verwenden wir notes.example.com
Das HedgeDoc-Modul kann die passende nginx-Konfiguration automatisch erzeugen:
{
services.hedgedoc = {
enable = true;
configureNginx = true; # <----
settings.domain = "notes.example.com"; # <----
};
}
configureNginx = true wird nginx als Abhängigkeit aktiviert und ein virtueller
Host für die angegebene Domain angelegt. Beim Aktivieren der Konfiguration erhalten
wir jedoch eine Fehlermeldung, die uns darüber Informiert, dass kein TLS Zertifikat
für diesen vHost verfügbar ist. Dies hängt damit zusammen, dass das hedgedoc
NixOS-Modul die Option forceSSL im nginx vHost auf true setzt, welche dann ein für
diese Domain gültiges TLS Zertifkat erfordert. Zunächst deaktivieren wir dieses
verhalten des Moduls, um den Dienst über nginx mittels http bereitzustellen.
{
services = {
hedgedoc = {
enable = true;
configureNginx = true;
settings.domain = "notes.example.com";
};
nginx.virtualHosts."notes.example.com".forceSSL = false; # <----
};
}
Auch dies führt zu einer Fehlermeldung, da die Option bereits definiert ist. Die Lösung für dieses Problem wird im nächsten Kapitel erläutert.
NixOS config merge¶
An dieser Stelle kommt eine wichtige Eigenschaft von NixOS zum Einsatz: Mehrere Konfigurationsfragmente können dieselbe Option setzen. NixOS muss deshalb entscheiden welcher Wert verwendet wird. Dafür gibt es nummerische Prioritäten. Der normale Standardwert einer Option wird mit der Priorität 100 behandelt. Mit lib.mkOverride können wir einen Wert mit einer anderen Priorität definieren. Eine niedrigere Zahl hat dabei eine höhere Priorität.
{ lib, ... }:
{
services = {
hedgedoc = {
enable = true;
configureNginx = true;
settings.domain = "notes.example.com";
};
nginx.virtualHosts."notes.example.com".forceSSL = lib.mkOverride 99 false;
};
}
{ lib, ... }:
{
services = {
hedgedoc = {
enable = true;
configureNginx = true;
settings.domain = "notes.example.com";
};
nginx.virtualHosts."notes.example.com".forceSSL = lib.mkOverride 99 false;
};
networking.firewall.allowedTCPPorts = [ 80 ]; # <----
}
TLS¶
HedgeDoc ist nun über nginx von anderen Computern aus erreichbar. Die Verbindung verwendet allerdings das unverschlüsselte HTTP Protokoll. Für den produktiven Betrieb sollten wir TLS aktivieren, damit Anmeldedaten, Dokumentinhalte und Sitzungen nicht unverschlüsselt übertragen werden.
Für Zertifikate gibt es verschiedene Möglichkeiten. Für die interne Nutzung in einer Umgebung wie beispielsweise einem Unternehmen kann man eine eigene PKI Infrasturktur aufbauen und eigene Zertifikate erstellen. Auf allen Systemen welche den Dienst nutzen sollen, muss dann ein root CA hinterlegt werden, durch welches die Gültigkeit des Zertifikats geprüft werden kann. Mehr dazu im Kapitel Fortgeschrittene Konzepte -> PKI.
Alternativ können Zertifikate über externe Zertifizierungsstellen wie DigiCert, GlobalSign oder GoDaddy bezogen werden. Unternehmen nutzen diese Anbieter häufig, um von zusätzlichen Garantieleistungen, erweiterten Validierungsstufen (OV/EV) oder umfassenden Supportangeboten zu profitieren.
Einige Organisationen bieten kostenlose TLS-Zertifikate an. Der bekannteste Anbieter ist Let’s Encrypt, eine von der gemeinnützigen Internet Security Research Group (ISRG) betriebene Zertifizierungsstelle. Das Projekt wurde 2015 gestartet und stellt kostenlose und automatisiert ausgestellte TLS-Zertifikate bereit, um verschlüsselte Verbindungen per HTTPs zu fördern und deren Nutzung als Standard im Internet voranzutreiben.
Neben Let’s Encrypt bieten auch Anbieter wie ZeroSSL und Actalis kostenlose Zertifizierungsdienste an. Sie unterstützen das ACME-Protokoll (Automatic Certificate Management Environment), mit dem sich Zertifikate automatisiert ausstellen und verlängern lassen.
Diese kostenlosen Zertifikate sind von den gängigen Browsern als vertrauenswürdig anerkannt und eignen sich hervorragend für öffentliche Websites, Webanwendungen und APIs. Im Vergleich zu kommerziellen Angeboten bieten sie zwar keine zusätzlichen Garantieleistungen oder erweiterte Validierung (z. B. EV-Zertifikate), erfüllen aber auf technischer Ebene die selben Sicherheitsstandards.
Das ACME-Protokoll definiert verschiedene Challenges,
die zur Validierung der Domainkontrolle verwendet werden. Diese Validierung ist Voraussetzung für
die Ausstellung eines Zertifikats. Das ACME-Modul von NixOS unterstützt derzeit die Challenges
ACME-HTTP-01 und ACME-DNS-01. Im folgenden Beispiel wird die ACME-HTTP-01-Challenge verwendet.
Dabei wird eine Datei im Verzeichnis .well-known/acme-challenge/ des Webroots bereitgestellt.
Die Zertifizierungsstelle ruft diese Datei anschließend über HTTP auf. Ist die Datei unter der
erwarteten URL erreichbar, gilt damit als nachgewiesen, dass der Antragsteller die Domain
kontrolliert beziehungsweise den Webserver für diese Domain konfigurieren kann.
Wir entfernen das Überschreiben des Parameters forceSSL, aktivieren enableACME für den nginx VirtualHost, akzeptieren die Terms of Service des verwendeten Zertifikatsdienstleisters und geben den für HTTPs genutzten Port 443 in der Firewall frei.
{
services = {
hedgedoc = {
enable = true;
configureNginx = true;
settings.domain = "notes.example.com";
};
nginx.virtualHosts."notes.example.com".enableACME = true; # <----
};
security.acme.acceptTerms = true;
networking.firewall.allowedTCPPorts = [
80
443 # <----
];
}
PostgreSQL¶
Standardmäßig verwendet HedgeDoc SQLite um Daten persistent zu speichern. Das ist für eine minimale Installation praktisch, weil keine zusätzliche Datenbank eingerichtet werden muss. Für einen produktiven Betrieb ist eine separate Datenbank wie PostgreSQL jedoch die bessere Wahl, da bei mehreren gleichzeitigen Zugriffen und schreibintensiven Anwendungen eine dateibasierte Datenbank an ihre Grenzen stößt.
{
services = {
postgresql = { # <----
enable = true;
ensureDatabases = [ "hedgedoc" ];
ensureUsers = [
{
name = "hedgedoc";
ensureDBOwnership = true;
}
];
};
hedgedoc = {
enable = true;
configureNginx = true;
settings = {
domain = "notes.example.com";
db = { # <----
dialect = "postgresql";
host = "/run/postgresql";
};
};
};
nginx.virtualHosts."notes.example.com".enableACME = true;
};
security.acme.acceptTerms = true;
networking.firewall.allowedTCPPorts = [
80
443
];
}
Web Application Firewall¶
Eine Web Application Firewall (WAF) schützt Webanwendungen auf HTTP-Ebene, indem sie nicht nur IP-Adressen und Ports, sondern auch die Inhalte von HTTP-Anfragen analysiert. Dadurch kann sie verdächtige Muster erkennen und beispielsweise SQL-, SSTI- und Command-Injection-Versuche sowie Cross-Site-Scripting blockieren.
Im folgenden wird ModSecurity mit
dem OWASP Core Ruleset (CRS) in Nginx integriert. Requests, die einen zuvor
definierten Anomaly Score überschreiten, werden automatisch protokolliert
(SecRuleEngine DetectionOnly) oder abgelehnt (SecRuleEngine On).
Dadurch lassen sich bestimmte Angriffe auf HedgeDoc oder verwendete Bibliotheken
bereits vor ihrer Verarbeitung durch die Anwendung abfangen.
Für die meisten Anwendungen müssen einzelne Regeln des CRS deaktiviert oder angepasst
werden, da sie andernfalls legitime Anfragen blockieren können. Bei HedgeDoc betrifft
dies beispielsweise die für die kollaborative Bearbeitung erforderliche WebSocket
basierte Kommunikation (/socket.io/) sowie die Funktion zum Löschen von Notes
(/history/).
Dazu laden wir github:coreruleset/coreruleset in der derzeit aktuellsten Version v4.29.0 in den Nix Store, erstellen die ModSecurity Konfiguration im Nix Store und passen die Konfiguration von nginx entsprechend an, sodass Modsecurity als zusätzliches Modul geladen und mit der zuvor erstellten Konfiguration aktiviert wird:
{ pkgs, ... }:
{
services = {
postgresql = {
enable = true;
ensureDatabases = [ "hedgedoc" ];
ensureUsers = [
{
name = "hedgedoc";
ensureDBOwnership = true;
}
];
};
hedgedoc = {
enable = true;
configureNginx = true;
settings = {
domain = "notes.example.com";
db = {
dialect = "postgresql";
host = "/run/postgresql";
};
};
};
nginx = # <----
let
modsecurity_crs = pkgs.fetchFromGitHub {
owner = "coreruleset";
repo = "coreruleset";
rev = "v4.29.0";
hash = "sha256-NvBwGFgch+7tnGjcpm47n0mGy0vj5i+JWtb7d033u2s=";
};
modsecurity_conf = pkgs.writeText "modsecurity.conf" ''
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec.json
SecAuditLogFormat JSON
SecAuditLogParts ABIJDEFHZ
SecRuleEngine On
SecDefaultAction "phase:1,log,auditlog,pass"
SecDefaultAction "phase:2,log,auditlog,pass"
SecRuleRemoveByTag "platform-iis"
SecRuleRemoveByTag "platform-windows"
# socket.io endpoint: Request content type is not allowed by policy
SecRule REQUEST_HEADERS:Host "@streq notes.example.com" \
"chain, id:10000, phase:1, pass, nolog"
SecRule REQUEST_URI "@beginsWith /socket.io/" \
"ctl:ruleRemoveById=920420"
# Delete note: Method is not allowed by policy
SecRule REQUEST_HEADERS:Host "@streq notes.example.com" \
"chain, id:10001, phase:1, pass, nolog"
SecRule REQUEST_URI "@beginsWith /history/" \
"ctl:ruleRemoveById=911100"
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=2"
SecAction "id:900010,phase:1,pass,t:none,nolog,setvar:tx.enforce_bodyproc_urlencoded=1"
SecAction "id:900990,phase:1,pass,t:none,nolog,setvar:tx.crs_setup_version=400"
Include ${modsecurity_crs}/rules/*.conf
'';
in
{
virtualHosts."notes.example.com".enableACME = true;
additionalModules = [ pkgs.nginxModules.modsecurity ];
appendHttpConfig = ''
modsecurity on;
modsecurity_rules_file ${modsecurity_conf};
'';
};
};
security.acme.acceptTerms = true;
networking.firewall.allowedTCPPorts = [
80
443
];
}
Secrets Management¶
Beim Blick in die HedgeDoc-Logs (systemctl status hedgedoc bzw.
journalctl -u hedgedoc) fällt folgende Meldung auf:
Session secret not set. Using random generated one.
Please set
sessionSecretin your config.json file.All users will be logged out.
HedgeDoc verwendet ein Session-Secret, um die Sitzungs-Cookies der Benutzer zu signieren beziehungsweise zu validieren. Wird bei jedem Start ein neues zufälliges Secret erzeugt, können bereits vorhandene Cookies nach einem Neustart nicht mehr überprüft werden. Dadurch werden alle aktiven Benutzer abgemeldet.
Das Session-Secret muss daher dauerhaft gespeichert und bei jedem Start erneut verwendet werden.
Grundsätzlich ließe sich das Secret direkt über die NixOS-Option
services.hedgedoc.settings.sessionSecret setzen. Sensible Werte sollten jedoch
nicht unmittelbar in der Nix-Konfiguration hinterlegt werden, da diese im Nix Store,
welcher von allen Benutzern des Systems gelesen werden kann, gespeichert werden.
Stattdessen speichern wir Secrets in verschlüsselten Dateien außerhalb des Nix Stores. Für diesen Zweck verwenden wir sops zusammen mit sops-nix. Die Secrets bleiben dabei verschlüsselt im Repository und werden erst während der Aktivierung der NixOS-Konfiguration auf dem Zielsystem entschlüsselt. sops-nix legt die einzelnen Werte anschließend als geschützte Dateien im Dateisystem ab und kann deren Besitzer und Zugriffsrechte deklarativ festlegen.
Jedoch bietet HedgeDoc nicht die Möglichkeit das Secret direkt aus einer von
sops-nix bereitgestellten Datei einzulesen. Die Anwendung erwartet stattdessen
den entsprechenden Konfigurationswert beziehungsweise die Umgebungsvariable
CMD_SESSION_SECRET (HedgeDoc hieß früher CodiMD, daher das CMD).
Zunächst wird die flake.nix um sops-nix erweitert:
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
disko.url = "github:nix-community/disko";
sops-nix.url = "github:Mic92/sops-nix"; # <----
};
outputs = { nixpkgs, disko, sops-nix, ... }: { # <----
nixosConfigurations = {
server = nixpkgs.lib.nixosSystem {
modules = [
# ...
sops-nix.nixosModules.default # <----
];
};
};
};
}
ssh-to-age zu einem age Public Key konvertiert wurden.
Grundsätzlich sollte Schlüsselmaterial nicht mehrfach verwendet werden.
Abhängig von der jeweiligen Systemkonfiguration kann dies in diesem Fall
jedoch vertretbar sein.
Da wir das age Paket bisher nicht installiert haben, können wir ein
weiteres Feature von NixOS nutzen: DevShells. Mithilfe von
nix shell nixpkgs#age --extra-experimental-features "nix-command flakes"
können wir diese in der aktuellen Shell verfügbar machen. Alternativ
besteht natürlich die Möglichkeit das Paket permanent zu installieren,
da dieses jedoch im weiteren Verlauf nicht nötig ist und grundsätzlich
auf Serversystemen nur notwendige Pakete installiert sein sollten, wird
darauf bewusst verzichtet.
Ein age Keypair mit sicheren Unix Zugriffsrechten kann mithilfe der folgenden Befehle erstellt werden:
OLD_UMASK=$(umask)
umask 0177
age-keygen > ~root/private.key
umask ${OLD_UMASK}
Danach kann die .sops.yaml angelegt werden, in welcher die Schlüssel mit den jeweiligen Secret-Dateien verknüpft werden.
# .sops.yaml
keys:
- &nico age1x0hr45ueh3nu4xypjltmlssz9jexyalzwu4296600gs9mlpl95rq2ny2es
- ¬es age12lasmrsaj50hanwq743lljq94x0ax2ht5jf9dysauqt5s22263vs5ql6f0
creation_rules:
- path_regex: secrets.yaml
key_groups:
- age: [ *nico, *notes ]
sops secrets.yaml angelegt werden.
Wie bereits zuvor bei age ist auch sops derzeit nicht auf dem System hinterlegt.
Wieder kann eine Nix DevShell verwendet werden um den Befehl verfügbar zu machen.
Da dieser Befehl im weiteren Verlauf des Blogartikels häufiger benötigt wird
können wir ihn alternativ permanent verfügbar machen, in dem wir unsere Nix
Konfiguration um environment.systemPackages = [ pkgs.sops ]; erweitern.
In der Datei secrets.yaml fügen wir folgenden Inhalt mit zufallsgeneriertem Passwort hinzu:
hedgedoc:
sessionSecret: My_S3cure-R4nd0m-S3cr3t
{ pkgs, config, ... }:
{
sops = {
defaultSopsFile = ./secrets.yaml;
age = {
keyFile = "/root/private.key";
sshKeyPaths = [ ];
};
secrets."hedgedoc/sessionSecret" = { };
templates."hedgedoc/environment".content = ''
CMD_SESSION_SECRET=${config.sops.placeholder."hedgedoc/sessionSecret"}
'';
};
services = {
postgresql = {
enable = true;
ensureDatabases = [ "hedgedoc" ];
ensureUsers = [
{
name = "hedgedoc";
ensureDBOwnership = true;
}
];
};
hedgedoc = {
enable = true;
configureNginx = true;
settings = {
domain = "notes.example.com";
db = {
dialect = "postgresql";
host = "/run/postgresql";
};
};
environmentFile = config.sops.templates."hedgedoc/environment".path;
};
nginx =
let
modsecurity_crs = pkgs.fetchFromGitHub {
owner = "coreruleset";
repo = "coreruleset";
rev = "v4.29.0";
hash = "sha256-NvBwGFgch+7tnGjcpm47n0mGy0vj5i+JWtb7d033u2s=";
};
modsecurity_conf = pkgs.writeText "modsecurity.conf" ''
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec.json
SecAuditLogFormat JSON
SecAuditLogParts ABIJDEFHZ
SecRuleEngine On
SecDefaultAction "phase:1,log,auditlog,pass"
SecDefaultAction "phase:2,log,auditlog,pass"
SecRuleRemoveByTag "platform-iis"
SecRuleRemoveByTag "platform-windows"
# socket.io endpoint: Request content type is not allowed by policy
SecRule REQUEST_HEADERS:Host "@streq notes.example.com" \
"chain, id:10000, phase:1, pass, nolog"
SecRule REQUEST_URI "@beginsWith /socket.io/" \
"ctl:ruleRemoveById=920420"
# Delete note: Method is not allowed by policy
SecRule REQUEST_HEADERS:Host "@streq notes.example.com" \
"chain, id:10001, phase:1, pass, nolog"
SecRule REQUEST_URI "@beginsWith /history/" \
"ctl:ruleRemoveById=911100"
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=2"
SecAction "id:900010,phase:1,pass,t:none,nolog,setvar:tx.enforce_bodyproc_urlencoded=1"
SecAction "id:900990,phase:1,pass,t:none,nolog,setvar:tx.crs_setup_version=400"
Include ${modsecurity_crs}/rules/*.conf
'';
in
{
virtualHosts."notes.example.com".enableACME = true;
additionalModules = [ pkgs.nginxModules.modsecurity ];
appendHttpConfig = ''
modsecurity on;
modsecurity_rules_file ${modsecurity_conf};
'';
};
};
security.acme.acceptTerms = true;
networking.firewall.allowedTCPPorts = [
80
443
];
}
sops updatekeys secrets.yaml aktualisiert werden.
Die Verwendung einer Umgebungsvariablen ist zwar sicherer, als das Secret direkt
in der Nix-Konfiguration zu hinterlegen. Sie bringt jedoch einen weiteren Nachteil
mit sich: Umgebungsvariablen sind an den jeweiligen Prozess gebunden und können je
nach Systemkonfiguration über das proc-Dateisystem ausgelesen werden
(/proc/{pid}/environ). Ein Prozess mit ausreichenden Berechtigungen (z. B.
ptrace) kann dadurch auf die Umgebungsvariablen anderer Prozesse zugreifen und
somit auch das Session-Secret einsehen.
Mit LoadCredential bietet das verwendete Init-System systemd zwar einen
geeigneteren Mechanismus, der Zugangsdaten beim Start des Dienstes in einem
geschützten temporären Verzeichnis bereitstellt – HedgeDoc unterstützt jedoch
nach wie vor das Auslesen von Secrets aus Dateien nicht, weshalb sich dieser
Ansatz hier nicht nutzen lässt.
Single Sign-On (SSO)¶
HedgeDoc kann die Authentifizierung an einen externen Identity Provider (IdP) auslagern. Benutzerkonte und Passwörter müssen dadurch nicht mehr ausschließlich in HedgeDoc verwaltet werden, sondern können zentral über den IdP gepflegt werden.
Ein weiterer Vorteil ist Single Sign-on (SSO): Nach einer erfolgreichen Anmeldung am Identity Provider können Benutzer auf alle angebundenen Anwendungen zugreifen, ohne sich dort erneut anmelden zu müssen. Die Anwendungen leiten den Benutzer zur Anmeldung an den IdP weiter und erhalten anschließend die für die eigene Sitzung benötigten Authentifizierungsinformationen.
Im zweiten Teil dieses Blogartikels konfigurieren wir HedgeDoc so, dass die Authentifizierung über OpenID Connect (OIDC) und Keycloak erfolgt. Keycloak ist eine Open-Source-Plattform für Identity- und Access-Management und übernimmt in diesem Aufbau die zentrale Verwaltung der Benutzer sowie deren Anmeldung.
Während sich einige Anwendungen wie HedgeDoc vollständig über Nix konfigurieren lassen, gibt es auch Ausnahmen. Keycloak ist ein Beispiel dafür: Zwar kann NixOS Keycloak auf dem System installieren und als Dienst betreiben, die eigentliche Konfiguration von Benutzern und OIDC-Clients erfolgt standardmäßig jedoch manuell über die Weboberfläche.
Info
Das NixOS-Modul für Keycloak unterstützt die Konfiguration von Realms über
JSON-Dateien (services.keycloak.realmFiles). Damit ließen sich die
Realm-Einstellungen ebenfalls deklarativ verwalten. Auf diese Variante gehen
wir hier jedoch nicht näher ein, da sie ein genaueres Verständnis der
Keycloak-Datenstruktur und der Realm-Konfiguration erfordert. Stattdessen
nehmen wir die erforderlichen Einstellungen zunächst über die Weboberfläche vor.
Systemhärtung¶
Unter Systemhärtung versteht man alle Maßnahmen, die darauf abzielen, die Angriffsfläche eines Systems zu reduzieren und dessen Sicherheit zu erhöhen. Dazu gehören beispielsweise das Deaktivieren nicht benötigter Dienste, die Einschränkung von Zugriffsrechten, eine gezielte Konfiguration von Netzwerkfunktionen sowie die Absicherung administrativer Zugänge.
Die folgenden Kapitel beschreiben ausgewählte technische Härtungsmaßnahmen unter NixOS. Sie sind als Beispiele und Anregungen zu verstehen und stellen keine allgemeingültige Konfiguration für jedes System dar.
Der wichtigste Hinweis lautet daher: Systemhärtung ist immer systemspezifisch. Die erforderlichen Einstellungen hängen von der jeweiligen Aufgabe, der Netzwerkumgebung und dem individuellen Bedrohungsmodell ab. Ein System, das als Router eingesetzt wird, benötigt beispielsweise IP-Forwarding. Wird diese Funktion aus Sicherheitsgründen deaktiviert, kann es seine eigentliche Aufgabe nicht erfüllen. Ein SSH-Jump-Host kann wiederum Agent-Forwarding oder andere spezielle SSH-Funktionen benötigen, die auf einem gewöhnlichen Server möglicherweise bewusst abgeschaltet werden sollten.
Eine sinnvolle Härtung besteht deshalb nicht darin, möglichst viele Funktionen pauschal zu deaktivieren. Entscheidend ist vielmehr, nur die tatsächlich benötigten Dienste und Berechtigungen zu aktivieren, ihre Verwendung gezielt einzuschränken und die Konfiguration regelmäßig zu überprüfen. Sicherheit, Funktionalität und Wartbarkeit müssen dabei stets gemeinsam betrachtet werden.
Benutzer¶
Bisher erfolgte der Zugriff auf den Server über den Benutzer root, dessen Anmeldung per SSH ausdrücklich erlaubt war. Da root über uneingeschränkte Rechte verfügt, werden alle ausgeführten Befehle unmittelbar mit den höchstmöglichen Privilegien ausgeführt. Für viele administrative Aufgaben ist das jedoch nicht erforderlich. Ein Fehler bei der Eingabe oder Ausführung eines Befehls kann dadurch weitreichende Auswirkungen auf das gesamte System haben. Insbesondere in Umgebungen mit mehreren Administratoren ist die Verwendung personalisierter Benutzerkonten empfehlenswert. So können Zugriffe und Änderungen eindeutig einzelnen Personen zugeordnet werden.
Für jeden Administrator wird zunächst ein eigener Benutzeraccount angelegt, dass Passwort kann mithilfe von Secrets ebenfalls deklarativ festgelegt werden. Dieser wird der Gruppe wheel hinzugefügt, wodurch er administrative Befehle über sudo ausführen kann. Die erhöhten Rechte werden somit nur gezielt und für einzelne Befehle verwendet. Anschließend wird die direkte Anmeldung von root über SSH deaktiviert.
{ config, ... }:
{
sops.secrets."passwords/nico".neededForUsers = true;
users.users.nico = {
isNormalUser = true;
extraGroups = [ "wheel" ];
hashedPasswordFile = config.sops.secrets."passwords/nico".path;
};
services.openssh.settings.PermitRootLogin = "no";
}
Mutable Users¶
Wenn das System ausschließlich von Administratoren genutzt und vollständig deklarativ über NixOS verwaltet wird, kann es sinnvoll sein, veränderliche Nutzer zu deaktivieren. Dadurch wird verhindert, dass Benutzer außerhalb der NixOS-Konfiguration angelegt, geändert oder gelöscht werden. Auch Änderungen wie Passwortänderungen über klassische Systemwerkzeuge sind anschließend nicht mehr dauerhaft möglich.
{
users.mutableUsers = false;
}
Password policy¶
Andernfalls ist eine Passwortrichtlinie empfehlenswert, welche die Verwendung von trivialen oder bereits zuvor verwendete Passwörter verhindert. Die für das PAM Modul pwquality verwendbaren Parameter können der manpage entnommen werden.
{ pkgs, config, lib, ... }:
{
security.pam.services.passwd.rules.password = {
pwquality = {
control = "required";
modulePath = "${pkgs.libpwquality.lib}/lib/security/pam_pwquality.so";
# order BEFORE pam_unix.so
order = config.security.pam.services.passwd.rules.password.unix.order - 10;
settings = {
minlen = 24;
dcredit = (-2); # digits
lcredit = (-3); # lowercase
ucredit = (-3); # uppercase
ocredit = (-1); # other
maxrepeat = 3; # amount of consecutive characters
enforce_for_root = true;
};
};
unix = {
control = lib.mkForce "required";
settings.use_authtok = true;
};
};
}
SSH Keys¶
Die Authentifizierung über SSH-Schlüssel bietet gegenüber der passwortbasierten Anmeldung sowohl Sicherheits- als auch Komfortvorteile, da sie das Risiko von Phishing, Keyloggern und wiederverwendeten Passwörtern reduziert und der private Schlüssel nach dem einmaligen Entsperren für die Dauer der Sitzung genutzt werden kann, ohne die Passphrase bei jeder Verbindung erneut eingeben zu müssen.
{
services.openssh.settings.PasswordAuthentication = false;
users.users.nico.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBhgHhBf2mK4BwbrBsJREYMfQJ2jNfhOtRt61EV/hsxV"
];
}
fail2ban¶
Ist das System aus nicht vertrauenswürdigen Netzwerken erreichbar (z. B. Internet), ist der Einsatz von fail2ban empfehlenswert. Der Dienst überwacht fehlgeschlagene Anmeldeversuche und sperrt IP-Adressen, von denen innerhalb eines bestimmten Zeitraums wiederholt verdächtige Zugriffe ausgehen.
{
services.fail2ban = {
enable = true;
maxretry = 5;
bantime = "8h";
};
}
SSH¶
Neben den im vorherigen Kapitel beschriebenen Einstellungen bietet SSH zahlreiche weitere Konfigurationsoptionen, die abhängig vom Einsatzzweck des Systems geprüft und angepasst werden sollten. Auf den meisten Anwendungsservern wird beispielsweise kein TCP/SSH Forwarding benötigen und können somit deaktiviert werden:
{
services.openssh.settings = {
AllowTcpForwarding = false;
AllowAgentForwarding = false;
};
}
Nix¶
Der Nix-Daemon unseres Systems erlaubt standardmäßig die Interaktion mit allen Benutzern. Dadurch können diese beispielsweise dynamisch Software mit nix shell oder nix run nachladen und ausführen. Auf einem Server ist dieses Verhalten in der Regel nicht erforderlich und kann einem Angreifer zusätzliche Möglichkeiten bieten, eigene Software auf das System zu bringen. Daher empfiehlt es sich, den Zugriff auf den Nix-Daemon auf administrative Benutzer zu beschränken.
{
nix.settings.allowed-users = [ "@wheel" ];
}
procfs¶
Im Secrets Management Kapitel wurde erläutert, dass Benutzer mit bestimmten Rechten,
beispielsweise ptrace, auf die Umgebungsvariablen anderer Prozessen zugreifen können.
Mit der procfs Mount-Option hidepid=2 lässt sich der Zugriff auf Prozessinformationen
für andere Benutzer einschränken.
{
fileSystems."/proc" = {
fsType = "proc";
device = "proc";
options = [
"nosuid"
"nodev"
"noexec"
"hidepid=2"
];
neededForBoot = true;
};
}
usbguard¶
Auf dedizierter Server-Hardware werden USB-Geräte häufig nur für Wartungsarbeiten verwendet. Tastatur, Installationsmedium oder externe Datenträger sind im laufenden Betrieb normalerweise nicht erforderlich. USBGuard kann deshalb dazu verwendet werden, nicht freigegebene USB-Geräte abzulehnen.
Das reduziert beispielsweise das Risiko, dass ein sogenanntes BadUSB Gerät als Tastatur oder Netzwerkadapter auftritt.
{
services.usbguard = {
enable = true;
IPCAllowedGroups = [ "wheel" ];
presentControllerPolicy = "apply-policy";
rules = ''
# internals
allow id 1d6b:0002 serial "0000:00:1a.0" name "EHCI Host Controller" hash "ej1WVedyLyUMLiQxzEcrwbY45zCodwV85Kzy7hm2Gv4=" parent-hash "e/RW0mMbM+TSFQxpRiMEfL7/3RJfKVdqffBm9F5qA+E=" with-interface 09:00:00
allow id 1d6b:0001 serial "0000:01:00.4" name "UHCI Host Controller" hash "ne9MN86uM98TZw0LqhyL7OKEHJUygyWvOjNn1b7Bz+E=" parent-hash "EcoU0c5jMQvq9JSIgu5Ho4RdsdfPb6n13C6Y3wIzu8A=" with-interface 09:00:00
allow id 1d6b:0002 serial "0000:00:14.0" name "xHCI Host Controller" hash "4G5jM1710eMHdxBgzKu2iYUFH6ol62c9Vx62XFHYQFM=" parent-hash "ej1WVedyLyUMLiQxzEcrwbY45zCodwV85Kzy7hm2Gv4=" with-interface 09:00:00
allow id 1d6b:0003 serial "0000:00:14.0" name "xHCI Host Controller" hash "MMn/OTrO6E00ngK2hDw0p2Q5Qi8H5me5tOmYALU4gm8=" parent-hash "ej1WVedyLyUMLiQxzEcrwbY45zCodwV85Kzy7hm2Gv4=" with-interface 09:00:00
allow id 8087:800a serial "" name "" hash "oHMq3XbdbTUsMz5PFkIzzYMCE7ad0v00udn0iZ88B2Q=" parent-hash "ej1WVedyLyUMLiQxzEcrwbY45zCodwV85Kzy7hm2Gv4=" via-port "1-1" with-interface 09:00:00
allow id 8087:8002 serial "" name "" hash "qIGkfqX7sv9oTIomveqlZ93sazJ7vBpO2+lAls7F8UQ=" parent-hash "ej1WVedyLyUMLiQxzEcrwbY45zCodwV85Kzy7hm2Gv4=" via-port "2-1" with-interface 09:00:00
# peripheral
allow id 046a:c122 serial "" name "CHERRY USB Keyboard" hash "KDR4ikabgRgNdISC+g/6BjObDBJi8I8UuyiBNOevd3A=" with-interface { 03:01:01 03:00:00 }
'';
};
}
Firewall¶
Wie bei der Netzwerkkonfiguration unterstützt NixOS verschiedene Firewall-Implementierungen, darunter iptables, nftables und firewalld. In diesem Kapitel verwenden wir nftables, da es die umfangreiche Modulauswahl von iptables mit einer übersichtlicheren und besser lesbaren Regelsyntax verbindet.
Das integrierte NixOS-Firewall-Modul eignet sich vor allem für einfache Regeln zu eingehenden Verbindungen. In unserer Infrastruktur benötigen wir jedoch zusätzlich Regeln für ausgehenden Datenverkehr. Später kommen auf dem Router außerdem Weiterleitungen und NAT-Regeln hinzu. Da das NixOS-Modul dafür nicht genügend Flexibilität bietet, definieren wir die Firewall-Regeln vollständig manuell mit nftables.
{
networking.nftables = {
enable = true;
tables.nixos-fw = {
enable = true;
name = "nixos-fw";
family = "inet";
content = ''
chain input-allow {
icmp type echo-request accept
icmpv6 type {
echo-request, echo-reply,
nd-neighbor-solicit, nd-neighbor-advert,
nd-router-solicit, nd-router-advert,
parameter-problem, destination-unreachable, packet-too-big, time-exceeded
} accept
# SSH, HTTP und HTTPs (udp für HTTP/3 (QUIC))
tcp dport ssh accept
tcp dport http accept
meta l4proto { tcp, udp } th dport https accept
}
chain output-allow {
icmp type echo-request accept
icmpv6 type {
echo-request, echo-reply,
nd-neighbor-solicit, nd-neighbor-advert,
nd-router-solicit, nd-router-advert,
parameter-problem, destination-unreachable, packet-too-big, time-exceeded
} accept
# DNS, NTP, HTTP und HTTPs (udp für HTTP/3 (QUIC))
udp dport domain accept
udp dport ntp accept
tcp dport http accept
meta l4proto { tcp, udp } th dport https accept
}
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept comment "trusted interfaces"
ct state vmap {
established : accept,
related : accept,
invalid : drop,
new : jump input-allow,
untracked : jump input-allow
}
}
chain output {
type filter hook output priority filter; policy drop
oifname lo accept comment "trusted interfaces"
ct state vmap {
established : accept,
related : accept,
invalid : drop,
new : jump output-allow,
untracked : jump output-allow
}
reject
}
chain forward {
type filter hook forward priority filter; policy drop;
}
'';
};
};
}
Das Regelwerk von nftables ist hierarchisch aufgebaut: Tables enthalten Chains, die wiederum aus Regeln bestehen.
Die input Chain wird über eine type Definition direkt an einen Netfilter-Hook gebunden. Sie wird dadurch registriert und automatisch für den entsprechenden Paketpfad ausgewertet. Die Chain input-allow wird hingegen über einen Jump aus der registrierten input-Chain aufgerufen und dient ausschließlich der strukturierten Ablage von Regeln für erlaubte eingehende Verbindungen.
Für das Verständnis von nftables ist der Paketpfad durch den Linux-Netzwerk-Stack entscheidend. Abhängig von Herkunft und Ziel durchläuft jedes Paket bestimmte Netfilter-Hooks:
- Pakete, die über ein Netzwerkinterface eintreffen, passieren zunächst den Hook prerouting.
- Anschließend entscheidet der Kernel anhand der Routing-Tabelle, ob das Paket für einen lokalen Prozess bestimmt ist oder weitergeleitet werden soll.
- Lokale Pakete durchlaufen den Hook input.
- Weiterzuleitende Pakete werden am Hook forward verarbeitet.
- Pakete, die von lokalen Prozessen erzeugt werden, beginnen am Hook output.
- Bevor ein Paket das System über ein Netzwerkinterface verlässt, wird es am Hook postrouting verarbeitet.
Folgendes Diagramm visualisiert diesen Flow:
flowchart TB
%% classDef raw fill:#f6a5a5,stroke:#333,color:#000
%% classDef mangle fill:#a9dcf5,stroke:#333,color:#000
%% classDef nat fill:#6a3d8f,stroke:#333,color:#fff
%% classDef filter fill:#7ee37e,stroke:#333,color:#000
%% classDef plain fill:#fff,stroke:#333,color:#000
%% Spalte 1
incoming_packet@{ shape: cloud, label: "Incoming Packet" } --> raw_prerouting[raw<br>PREROUTING] --> connection_state_tracking[["Connection<br>(state)<br>Tracking"]] --> mangle_prerouting[mangle<br>PREROUTING]
%% Spalte 2
nat_prerouting[nat<br>PREROUTING] --> for_this_host{For this<br>host?}
for_this_host -- Y --> mangle_input[mangle<br>INPUT] --> filter_input[filter<br>INPUT] --> local_processing([Local<br>Processing])
%% Spalte 3
for_this_host -- N --> mangle_forward[mangle<br>FORWARD] --> filter_forward[filter<br>FORWARD] --> mangle_postrouting[mangle<br>POSTROUTING] --> nat_postrouting[nat<br>POSTROUTING] --> outgoing_packet@{ shape: cloud, label: "Outgoing Packet" }
%% Spalte 4
mangle_output[mangle<br>OUTPUT] --> nat_output[nat<br>OUTPUT] --> routing_decision2[[Routing<br>Decision]] --> filter_output[filter<br>OUTPUT]
%% Spalte 5
locally_generated_packet([Locally-<br>generated<br>Packet]) --> routing_decision[[Routing<br>Decision]] --> raw_output[raw<br>OUTPUT] --> connection_state_tracking2[["Connection<br>(state)<br>Tracking"]]
%% Rückwärts-Pfeile: Layout von oben nach unten, Pfeilspitze nur oben
nat_prerouting <---- mangle_prerouting
mangle_postrouting <--> filter_output
mangle_output <----> connection_state_tracking2
locally_generated_packet <-....-> local_processing
linkStyle 18,19,20,21 marker-end:none
class raw_prerouting,raw_output raw
class mangle_prerouting,mangle_input,mangle_forward,mangle_postrouting,mangle_output mangle
class nat_prerouting,nat_postrouting,nat_output nat
class filter_input,filter_forward,filter_output filter
class incoming_packet,outgoing_packet,for_this_host,local_processing,locally_generated_packet,connection_state_tracking,connection_state_tracking2,routing_decision,routing_decision2 plain
Kernel¶
Der Linux-Kernel bildet die zentrale Grundlage des Systems und verfügt über weitreichende Zugriffsrechte. Eine gezielte Härtung reduziert daher die verfügbare Angriffsfläche, schränkt den Zugriff auf sensible Kernel- und Prozessinformationen ein und verhindert das Nachladen nicht benötigter Kernelkomponenten.
{
boot = {
kernelParams = [ "debugfs=off" ];
kernel.sysctl."kernel.yama.ptrace_scope" = 2;
};
security = {
lockKernelModules = true;
pam.loginLimits = [
{
domain = "*";
item = "core";
type = "hard";
value = "0";
}
{
domain = "*";
item = "core";
type = "soft";
value = "0";
}
];
};
}
Zusätzlich wurde der Laufzeitparameter kernel.yama.ptrace_scope so konfiguriert,
dass die Verwendung von ptrace nur mit der Capability CAP_SYS_PTRACE möglich ist.
ptrace ermöglicht es einem Prozess, andere Prozesse zu untersuchen und teilweise
deren Speicher zu verändern. Die Einschränkung reduziert daher das Risiko, dass ein
kompromittierter Prozess auf sensible Daten oder den Speicher anderer Prozesse zugreift.
Das Deaktivieren des automatischen Nachladens von Kernelmodulen reduziert sowohl die Angriffsfläche als auch das Risiko einer kernelbasierten Persistenz. Ein Angreifer kann dadurch nicht ohne Weiteres ein zusätzliches oder manipuliertes Kernelmodul aktivieren, um eine Schwachstelle auszunutzen oder ein Rootkit dauerhaft im System zu verankern, das bei späteren Systemstarts automatisch geladen wird.
Abschließend wurde die Erstellung von Core Dumps deaktiviert. Dabei handelt es sich um Speicherabbilder abgestürzter Prozesse, die zwar bei der Fehlersuche hilfreich sein können, unter Umständen jedoch Passwörter, kryptografische Schlüssel oder andere vertrauliche Laufzeitdaten enthalten. Auf Produktionsservern ist es daher sinnvoll, Core Dumps zu deaktivieren.
Fortgeschrittene Konzepte¶
Speicherlayout¶
Im Kapitel Installation -> Partitionierung wurde der Übersichtlichkeit halber zunächst eine einfache Speicheraufteilung verwendet. Neben der EFI-Systempartition bestand das Layout lediglich aus einer unverschlüsselten ext4-Partition für das root-Dateisystem.
Im Folgenden werden verschiedene abweichende Konfigurationen vorgestellt.
LUKS encrypted root¶
Das root Dateisystem sollte verschlüsselt werden. Zum Zeitpunkt der Installation kann die disk-config.nix entsprechend Angepasst werden. Disko fragt beim Partitionieren das Passwort für die Erstellung des Cryptsetups ab.
{
disko.devices = {
disk.disk1 = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
esp = {
name = "boot";
size = "511M";
type = "EF00"; # EFI system partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
name = "root";
size = "100%";
content = {
type = "luks";
name = "cryptedroot";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
};
}
Ein Neustart des Servers führt zur Aufforderung das Passwort einzugeben. Während dies bei Clientsystemen typischerweise kein Problem darstellt, werden Server häufig auch über das Netzwerk neugestartet, teilweise ist physikalischer Zugriff auf die Maschinen gar nicht möglich. Um Systeme entfernt neustarten zu können, kann im initrd ein SSH server gestartet werden, der dann das entschlüsseln des systems über ssh ermöglicht. Hierbei ist auf die korrekte Angabe der für Netzwerk benötigten kernel module zu achten.
{
boot.initrd = {
systemd = {
enable = true;
network.enable = false;
initrdBin = [
(pkgs.writeShellScriptBin "systemctl-default" ''
/bin/systemctl default
'')
];
users.root.shell = "/bin/systemctl-default";
};
kernelModules = [ "virtio_net" ];
network = {
enable = true;
ifstate = {
enable = true;
allowIfstateToDrasticlyIncreaseInitrdSize = true;
inherit (config.networking.ifstate) settings;
};
ssh = {
enable = true;
hostKeys = [ /etc/ssh/ssh_host_ed25519_key ];
};
};
};
}
Alternativ lässt sich bei vorhandenem TPM-Chip der Remote Unlock durch eine im TPM gespeicherte Entsperrinformation ersetzen. Dazu kann systemd-cryptenroll verwendet werden. Die Einrichtung erfolgt beispielsweise mit folgendem Befehl:
systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0 /dev/sda2
LVM¶
Eine gemeinsam genutzte root-Partition kann zum Ausfall des Systems führen, wenn beispielsweise zu viele Logdateien geschrieben wurden und dadurch kein freier Speicherplatz mehr verfügbar ist. In der Folge können Dienste wie Datenbanken möglicherweise nicht mehr gestartet werden. Aus sicherheitstechnischer Sicht kann es außerdem sinnvoll sein, zusätzliche logische Volumes anzulegen, um darauf spezifische Mount-Optionen wie noexec oder nosuid anzuwenden.
{
disko.devices = {
disk.disk1 = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
esp = {
name = "boot";
size = "511M";
type = "EF00"; # EFI system partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted";
settings.allowDiscards = true;
content = {
type = "lvm_pv";
vg = "pool";
};
};
};
};
};
};
lvm_vg = {
pool = {
type = "lvm_vg";
lvs = {
root = {
size = "2G";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/var/log";
mountOptions = [
"defaults"
"nosuid"
"nodev"
"noexec"
];
};
};
root = {
size = "100%FREE";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
mountOptions = [
"defaults"
];
};
};
};
};
};
};
}
ZFS¶
ZFS ist ein modernes Dateisystem mit integriertem Volume-Management. Gegenüber der bisher verwendeten Kombination aus LVM und ext4 bietet es zusätzliche Funktionen zur Verwaltung und Absicherung von Speicher.
Dazu zählen unter anderem RAID, Prüfsummen zur Erkennung von Datenfehlern, Snapshots, Replikation, transparente Kompression und native Verschlüsselung.
Nachteile sind insbesondere der höhere RAM-Bedarf und der zusätzliche Verwaltungsaufwand. ZFS eignet sich daher besonders für dedizierte Hardware mit ausreichend Arbeitsspeicher und mehreren physischen Datenträgern. Der Einsatz in einer virtuellen Maschine ist ebenfalls möglich, setzt jedoch geeignete virtuelle Datenträger, ausreichend zugewiesene Ressourcen und die Unterstützung durch den Hypervisor voraus.
Die native ZFS-Verschlüsselung schützt keine Metadaten. Je nach Anwendungsfall kann daher zusätzlich eine Full-Disk-Verschlüsselung mit LUKS sinnvoll sein. Datenhaltige ZFS-Datasets können dennoch ebenfalls durch ZFS verschlüsselt werden, um einfache verschlüsselte Backups mittels zfs send und zfs receive zu ermöglichen.
Folgende Disko Konfiguration zeigt die Partitionierung mit zwei physikalischen Festplatten, LUKS und ZFS
{
disko.devices = {
disk = {
disk1 = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
esp = {
name = "boot";
size = "511M";
type = "EF00"; # EFI system partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot/efis/ESP1";
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "disk1-crypted";
settings.allowDiscards = true;
content = {
type = "zfs";
pool = "rpool";
};
};
};
};
};
};
disk2 = {
device = "/dev/sdb";
type = "disk";
content = {
type = "gpt";
partitions = {
esp = {
name = "boot";
size = "511M";
type = "EF00"; # EFI system partition
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot/efis/ESP2";
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "disk2-crypted";
settings.allowDiscards = true;
content = {
type = "zfs";
pool = "rpool";
};
};
};
};
};
};
};
zpool = {
rpool = {
mode = {
topology = {
type = "topology";
vdev = [
{
mode = "raidz1";
members = [
"/dev/mapper/disk1-crypt"
"/dev/mapper/disk2-crypt"
];
}
];
};
};
rootFsOptions = {
mountpoint = "none";
compression = "zstd";
acltype = "posixacl";
xattr = "sa";
"com.sun:auto-snapshot" = "true";
};
options.ashift = "12";
datasets = {
"root" = {
type = "zfs_fs";
mountpoint = "/";
};
"nix" = {
type = "zfs_fs";
options.mountpoint = "/nix";
mountpoint = "/nix";
};
"log" = {
type = "zfs_fs";
options.mountpoint = "/var/log";
mountpoint = "/var/log";
};
};
};
};
};
}
Weitere Installationsmöglichkeiten¶
Neben der im Kapitel Installation beschriebenen Nutzung des offiziellen ISO-Images, lässt sich aufgrund des deklarativen Ansatzes auch ein angepasstes Image vergleichsweise einfach erstellen. Dabei können individuelle Konfigurationen direkt integriert werden:
{
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
outputs =
{ nixpkgs, ... }:
{
nixosConfigurations = {
customIso = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
"${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix"
(
{ lib, pkgs, ... }:
{
# enable serial console
boot.kernelParams = [ "console=ttyS0,115200n8" ];
users.users.root = {
initialHashedPassword = lib.mkForce null;
password = "s3cr3tPasSw0rt";
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICjJMDuek9Ug/Eqc1y6Sq65nLhrgLLSgJYTKlUEw6I13"
];
};
console.keyMap = "de";
}
)
];
};
};
};
}
nix build .#nixosConfigurations.customIso.config.system.build.isoImage
Des Weiteren existieren Tools wie github:elitak/nixos-infect oder github:nix-community/nixos-anywhere welche die Installation von NixOS aus nahezu jedem Linux-basierten System ermöglichen. Hierfür wird eine im Linux Kernel implementierte Funktion (kexec) verwendet, die ein neues Kernel-Image lädt und in dieses startet. Mithilfe dieser Werkzeuge kann NixOS auch auf einem Server installiert werden, wenn der Hosting-Anbieter weder ein NixOS-Image bereitstellt noch die Verwendung eigener ISO-Images erlaubt.
PKI¶
Bisher wurden Zertifikate über die ACME-HTTP-01-Challenge von Let’s Encrypt bezogen. Bei dieser Challenge muss der HTTP-Endpunkt der jeweiligen Domain öffentlich erreichbar sein, damit Let’s Encrypt die Domainvalidierung durchführen kann. Für interne Dienste, die beispielsweise durch eine Firewall vor dem Internet geschützt sind, ist dieses Verfahren daher nicht geeignet.
Eine Alternative stellt die ACME-DNS-01-Challenge dar. Dabei weist der ACME-Client die Kontrolle über eine Domain nach, indem er über eine DNS-API einen speziell erzeugten TXT-Record anlegt. Für die Validierung ist dadurch kein direkter Zugriff auf den eigentlichen Dienst erforderlich. Auf diese Weise können auch interne Dienste mit öffentlich vertrauenswürdigen Let’s-Encrypt-Zertifikaten betrieben werden.
Allerdings werden die Namen solcher Zertifikate in Certificate-Transparency-Logs veröffentlicht. Dadurch können auch interne Hostnamen oder Subdomains nach außen bekannt werden. Für interne Dienste, die beispielsweise ausschließlich Administratoren zur Verfügung stehen, kann deshalb der Betrieb einer eigenen Public-Key-Infrastruktur (PKI) sinnvoller sein.
Bei einer eigenen PKI wird eine interne Root-CA eingerichtet. Ihr Root-Zertifikat wird auf den Geräten der Administratoren als vertrauenswürdige Zertifizierungsstelle hinterlegt. Die internen Webserver erhalten anschließend Zertifikate, die von dieser CA oder einer untergeordneten Intermediate-CA signiert wurden. Browser und andere Clients können diese Zertifikate validieren, ohne dass sie von einer öffentlich vertrauenswürdigen CA ausgestellt sein müssen.
Der private Schlüssel der Root-CA muss besonders geschützt werden und sollte idealerweise offline aufbewahrt werden. Für den laufenden Betrieb empfiehlt sich eine Intermediate-CA, welche für die Ausstellung von Leaf Zertifikaten verwendet wird.
Darüber hinaus können mit einer eigenen PKI auch Clientzertifikate für Mutual TLS (mTLS) ausgestellt werden. Während bei gewöhnlichem TLS nur der Server seine Identität gegenüber dem Client nachweist, authentifizieren sich bei mTLS beide Kommunikationspartner gegenseitig. Der Server prüft dabei nicht nur, ob das präsentierte Clientzertifikat von der eigenen CA signiert wurde, sondern kann zusätzlich anhand von Zertifikatsattributen oder Seriennummern entscheiden, ob der jeweilige Client zugriffsberechtigt ist.
Das ist insbesondere für administrative Schnittstellen und besonders schützenswerte interne Dienste sinnvoll. Administratoren oder Systeme ohne gültiges Clientzertifikat können bereits während des TLS-Verbindungsaufbaus abgewiesen werden. Sie erreichen dadurch weder die Webanwendung noch deren Anmeldeseite. Ein kompromittiertes oder erratenes Benutzerpasswort reicht dann allein nicht aus, um Zugriff zu erhalten, da zusätzlich ein gültiger privater Schlüssel zum Clientzertifikat benötigt wird.
Die oben beschriebene Struktur könnte Beispielsweise so aussehen:
flowchart TB
root[Root CA] --- inter[Intermediate CA]
mTLS[mTLS Client Certificates]
TLS[TLS Server Certificates]
inter --- TLS([TLS Server Certificates])
inter --- mTLS([mTLS Client Certificates])
TLS --- git[git.example.com]
TLS --- grafana[grafana.example.com]
mTLS --- nico
Die Konfiguration im nginx könnte dann beispielsweise so aussehen:
{
services.nginx = {
commonHttpConfig = ''
map "$ssl_client_verify:$ssl_client_s_dn" $reject {
default 1;
"SUCCESS:CN=nico,O=example,C=DE" 0;
}
'';
virtualHosts."service.example.com".extraConfig = ''
ssl_client_certificate ${./root-ca.pem};
ssl_verify_client on;
if ($reject) { return 403; }
'';
};
}