Eigener Webserver mit offiziellen Zertifikaten

Der Basis-Artikel für alle folgenden Services, die ich hier demonstrieren werde...

Eigener Webserver mit offiziellen Zertifikaten

Die Bereitstellung eines sicheren Web-Servers im Internet stellt zweifellos eine anspruchsvolle Aufgabe dar. Dabei sind vor allem kosteneffiziente Lösungen gefragt, die keinerlei Sicherheitslücken aufweisen und stets mit den neuesten Patches aktualisiert sind. Eine zusätzliche Flexibilität für modulare Erweiterungen in der Zukunft wäre ebenfalls von Vorteil. Es ist entscheidend, dass der Zugriff auf die Seiten nicht über das unsichere HTTP, sondern über das professionelle und verschlüsselte HTTPS erfolgt. Die Frage nach der Beschaffung eines gültigen SSL/TLS-Zertifikats für HTTPS stellt sich dabei.

Um diesen komplexen Anforderungen gerecht zu werden, beginnen wir zunächst mit einer kostenlosen Lösung, die jedoch voll funktionsfähig ist. Beachten Sie, dass diese Lösung aufgrund ihrer Modularität problemlos an die Anforderungen von Unternehmen unterschiedlicher Größe angepasst werden kann. An entsprechenden Stellen werde ich auf Möglichkeiten zur Skalierung hinweisen.

Der Ansatz mit Docker-Containern

Es stellt sich heraus, dass Container hier extrem nützlich sind. Jeder hat seine Aufgabe. Und diese Module braucht man nur zusammenzusetzen und schon ist wieder ein Projekt abgeschlossen. Und wer es dann doch lieber größer haben möchte, stellt gegebenenfalls auf hochverfügbare Kubernetes Cluster um.

Das volle Programm

Wir werden hier also absolut alles aufsetzen, wie es auch eine große Firma tun würde. Unsere Kosten werden sich daher zwischen 0€ und 1,30€ pro Monat bewegen - inklusive der eigenen Domain!

Und so soll es später aussehen:

              +------------------------+   +--------------+
              |         Internet       |---|  DNS-Server  |
              +------------------------+   +--------------+
                                |
                                |
                                |
                      +------------------------+
                      |   Reverse Proxy        |
                      |   (Proxy Server)       |
                      +------------------------+
                                |
                                | virtuelles Netz
                                | "web"
                                |
             +------------------+------------------+------------- ...
             |                  |                  |
   +------------------+ +------------------+ +------------------+
   |   Web Server     | |   Doku-Wiki      | |   Blog-Server    | ...
   +------------------+ +------------------+ +------------------+
                                                   |
                                                   | virtuelles Netz
                                                   | "backend"
                                             +------------------+
                                             |   MySQL DB       |
                                             +------------------+

Was wir brauchen

Wir brauchen einige Komponenten, je nachdem wie aufwändig wir das Projekt umsetzen wollen:

  • DNS-Server: DynDNS (0€) oder eigene Domain (79ct..1,29€ pro Monat)
  • Server: Im Internet (Google/Oracle: 0€, Contabo für mehr bäm mit statischer IP) oder natürlich zu Hause (Stromrechnung beachten!)

DNS

Wie wollen wir unseren Server erreichen? Mit einem sprechenden Namen? Im Falle der niedrigen Kosten benötigen wir dazu einen Helfer: DynDNS etc.. Das liegt daran, dass sich die IP-Adresse zu hause oder auch bei Google dynamisch ändern kann. Damit wir unseren Server wieder finden können, muss er also sagen, welche IP er gerade hat. Ich habe mich dazu für https://www.dynu.com/ entschieden. Es gibt aber noch viele andere Anbieter.

Bei unserem DynDNS Anbieter erstellen wir uns einen Account und suchen uns einen Hostnamen aus:

Und das war es auch schon. Jetzt müssen wir unserem Server nur noch beibringen, dass er die IP-Adresse für den gewählten Hostnamen selbst aktualisiert, sobald er eine neue IP bekommen hat.

Ich mache die Updates am liebsten von Hand, damit ich genau sehen kann, ob alles funktioniert.

MYDOMAIN=mmtest.mywire.org
INTERNETIP=$(wget -q -O - http://checkip.dyndns.org | grep -Eo "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+")
if [ ! $(ping -W 1 -c 1 ${MYDOMAIN} | grep PING | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+') == ${INTERNETIP} ]
then echo $(wget --timeout=5 --no-check-certificate -qO - "https://api.dynu.com/nic/update?hostname=${MYDOMAIN}&myip=${INTERNETIP}&username=[YourUserHere]&password=[YourPasswordHere]")
fi

good 35.212.210.20

Dieses Bash-Skript führt mehrere Aktionen durch:

  1. Es setzt die Variable MYDOMAIN auf den Wert mmtest.mywire.org.
  2. Es verwendet den Befehl wget und grep, um die aktuelle öffentliche IP-Adresse des Internetzugangs zu ermitteln. Dies wird erreicht, indem die Website http://checkip.dyndns.org abgefragt und die IP-Adresse aus der Antwort extrahiert wird.
  3. Es verwendet den Befehl ping, um die IP-Adresse des Domänennamens ${MYDOMAIN} zu ermitteln und vergleicht sie mit der zuvor ermittelten öffentlichen IP-Adresse. Wenn die beiden IP-Adressen nicht übereinstimmen, wird der nachfolgende Block ausgeführt.
  4. Im Block wird erneut wget verwendet, um eine Anfrage an die Dynu DNS-Update-API zu senden, um die IP-Adresse des angegebenen Hostnamens zu aktualisieren. Die Parameter für den API-Aufruf sind in der URL enthalten und enthalten den Hostnamen (${MYDOMAIN}), die aktuelle IP-Adresse (${INTERNETIP}), sowie Benutzername und Passwort für die Dynu-DNS-Anmeldung. Dieser Block wird nur ausgeführt, wenn die IP-Adressen nicht übereinstimmen.

Beim ersten mal erhält man daher die Ausgabe good w.x.y.z. Beim nächsten mal ist ja kein Update mehr notwendig.  Dies lassen wir bei jedem Boot ausführen. Wir nutzen dazu den Befehl crontab -e:

@reboot { MYDOMAIN=mmtest.mywire.org ; INTERNETIP=$(wget -q -O - http://checkip.dyndns.org | grep -Eo "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+") ; if [ ! $(ping -W 1 -c 1 ${MYDOMAIN} | grep PING | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+') == ${INTERNETIP} ] ; then echo $(wget --timeout=5 --no-check-certificate -qO - "https://api.dynu.com/nic/update?hostname=${MYDOMAIN}&myip=${INTERNETIP}&username=[YourUserHere]&password=[YourPasswordHere]") ; fi ; } > /dev/null 2>&1

Zusätzlich prüfen wir alle 15 Minuten ob sich die IP geändert hat, damit wir auf keinen Fall den Kontakt verlieren. Ich editiere dazu die /etc/crontab:

*/15 *  * * *   root    { MYDOMAIN=mmtest.mywire.org ; INTERNETIP=$(wget -q -O - http://checkip.dyndns.org | grep -Eo "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+") ; if [ ! $(ping -W 1 -c 1 ${MYDOMAIN} | grep PING | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+') == ${INTERNETIP} ] ; then echo $(wget --timeout=5 --no-check-certificate -qO - "https://api.dynu.com/nic/update?hostname=${MYDOMAIN}&myip=${INTERNETIP}&username=[YourUserHere]&password=[YourPasswordHere]") ; fi ; } > /dev/null 2>&1

Jetzt prüfen wir mal, ob das auch aus dem Internet funktioniert:

michael@michael:~ $ host mmtest.mywire.org
mmtest.mywire.org has address 35.212.210.20

Super. Das sieht gut aus.

Bonus: Die eigene Domain

Wenn man jetzt noch eine eigene Domain haben möchte, muss man eine kaufen. Ich habe mir damals eine bei one.com für 79ct/Monat gekauft. Die Preise haben sich sicherlich leicht geändert, sollten sich aber im Bereich um 1€ pro Monat bewegen.

Das Ziel, das wir hier verfolgen, ist einen Alias anzulegen, der auf den beim DynDNS-Anbieter angelegten Hostnamen verweist. Am einfachsten legt man einen CNAME an, z.B. *.meine-domain.de, der auf mmtest.mywire.org verweist. Dann können wir beliebige Namen in unserer Domain verwenden. In meinem Falls sieht das dann so aus:

michael@michael:~ $ host mmgoogle1.meister-security.de
mmgoogle1.meister-security.de is an alias for mmtest.mywire.org.
mmtest.mywire.org has address 35.212.210.20

michael@michael:~ $ host www.meister-security.de
www.meister-security.de is an alias for mmtest.mywire.org.
mmtest.mywire.org has address 35.212.210.20

Perfekt. Schauen wir mal, ob wir den Server anpingen können:

michael@michael:~ $ ping mmgoogle1.meister-security.de
PING mmgoogle1.meister-security.de (35.212.210.20) 56(84) Bytes an Daten.
64 Bytes von mmgoogle1.meister-security.de (35.212.210.20): icmp_seq=1 ttl=54 Zeit=8.46 ms
64 Bytes von mmgoogle1.meister-security.de (35.212.210.20): icmp_seq=2 ttl=54 Zeit=10.9 ms
^C

Auch das sieht gut aus. Wir haben nun also einen Server im Internet, den wir mit dem Namen beliebig.meine-domain.de erreichen können.

Docker installieren

Jetzt, da wir den Server erreichen können, können wir uns um die Dienste kümmern. Docker zu installieren ist dabei sicherlich unsere leichteste Aufgabe. Wir kopieren einfach folgendes in die Linux-Console:

apt install docker.io wget -y
wget -qO /usr/local/bin/docker-compose "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)"
chmod +x /usr/local/bin/docker-compose

Container anlegen

Ich schlage vor, gleich ein wenig Ordnung einzuhalten. Zum Einen, was unsere virtuellen Netze betrifft. Zum Anderen, welche Container zusammen gehören - sogenannte Stacks.

Virtuelle Netze einrichten

Wir wollen den Traffic, der aus dem Internet kommt, zum Reverse-Proxy auf dem Server leiten. Also öffnen wir die Ports 80 und 443. Er kümmert sich dann um die Weiterleitung der URL an den gerufenen Container. Damit er das kann, sollte er auf das Netzwerk zugreifen können, in dem auch der Webserver-Container sein wird.

Der Webserver-Container sollte nicht direkt aus dem Internet erreichbar sein, deswegen binden wir ihn an das virtuelle Netz web an.

Falls der Webserver auf eine Datenbank zugreifen muss, legen wir die Datenbank natürlich nicht mit ins web-Segment, sondern ins backend

Diese Netze legen wir eben schnell an, da wir sie später bei anderen Stacks noch brauchen werden:

docker network create web
docker network create backend

Stacks vorbereiten

Ein Stack ist eigentlich nichts besonderes. Wenn man ein docker-compose.yml File mit mehreren Containern anlegt und startet, bekommt diese Gruppe einen Namen. Nämlich den, den der Ordner hat in dem das docker-compose.yml File liegt. So kann man Ordnung halten.

Ich habe alle meine Docker-Daten unterhalb von /var/docker angelegt. Folgende Struktur habe ich angelegt:

.
├── admin
│   ├── watchtower
|   └── portainer
└── web
    ├── nginx-proxy-manager
    └── website

Den Bereich admin werde ich in einem eigenen Post beschreiben. Legen wir also ersteinmal den Webserver an.

Der Webserver

Endlich die ersten Container. Diese werden in der Datei docker-compose.yml beschrieben, die wir im Ordner web anlegen. Wir beschreiben daher nun den Reverse-Proxy (nginx-proxy-manager) und den Webserver (nginx):

version: "3"
services:
###############################################
####        nginx-proxy-manager           #####
###############################################
  nginx-proxy-manager:
    image: 'jc21/nginx-proxy-manager:latest'
    container_name: "nginx-proxy-manager"
    restart: unless-stopped
    ports:
      # These ports are in format <host-port>:<container-port>
      - '80:80' # Public HTTP Port
      - '443:443' # Public HTTPS Port
      - '127.0.0.1:81:81' # Admin Web Port only per ssh-tunnel
      # Add any other Stream port you want to expose
      # - '21:21' # FTP
    environment:
      # Uncomment this if you want to change the location of
      # the SQLite DB file within the container
      # DB_SQLITE_FILE: "/data/database.sqlite"
      # Uncomment this if IPv6 is not enabled on your host
      DISABLE_IPV6: 'true'
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
    volumes:
      - ./nginx-proxy-manager/data:/data
      - ./nginx-proxy-manager/letsencrypt:/etc/letsencrypt
    networks:
      - web

  website:
    image: nginx:latest
    container_name: website
    volumes:
      - "./website:/usr/share/nginx/html"
    depends_on:
      - nginx-proxy-manager
    expose:
      - "80:80"
    restart: unless-stopped
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
    networks:
      - web

  networks:
    web:
      external: true
    backend:
      external: true

Dieses funktionsfähige Dateibeispiel beschreibt zwei Docker-Dienste: nginx-proxy-manager und website. Hier ist eine Erklärung für jede Sektion:

Dienst: nginx-proxy-manager

  • image: Der Docker-Image-Name für den Dienst ist jc21/nginx-proxy-manager:latest. Dieser Dienst verwendet das Image "nginx-proxy-manager" in der neuesten verfügbaren Version.
  • container_name: Der Name des Containers wird auf "nginx-proxy-manager" festgelegt.
  • restart: Der Container wird automatisch neu gestartet, es sei denn, der Benutzer stoppt ihn explizit (unless-stopped). Das bedeutet, daß z.B. nach einem Reboot des Servers die Container automatisch wieder in Betrieb gehen.
  • ports: Die Ports, die vom Container nach außen verfügbar gemacht werden. In diesem Fall sind das Port 80 (HTTP), Port 443 (HTTPS) und Port 81 für das Admin-Webinterface. Der Admin-Webport ist auf 127.0.0.1:81 begrenzt, was bedeutet, dass er nur auf dem Hostsystem über localhost zugänglich ist.
  • environment: Umgebungsvariablen für die Konfiguration des nginx-proxy-manager-Dienstes. Hier wird DISABLE_IPV6 auf 'true' gesetzt, um die IPv6-Unterstützung zu deaktivieren.
  • labels: Docker-Labels, die für den Container gesetzt werden. Hier wird ein Label hinzugefügt, um Watchtower zu aktivieren, was es ermöglicht, den Container automatisch zu aktualisieren, wenn neue Images verfügbar sind. (Erklärung folgt in einem eigenen Post)
  • volumes: Gemappte Volumes, die es dem Container ermöglichen, auf bestimmte Verzeichnisse auf dem Hostsystem zuzugreifen. Hier werden zwei Volumes für die Datenbank und die Let's Encrypt-Zertifikate verwendet.

Dienst: website

  • image: Der Docker-Image-Name für diesen Dienst ist nginx:latest. Dieser Dienst verwendet das offizielle Nginx-Image in der neuesten verfügbaren Version.
  • container_name: Der Name des Containers wird auf "website" festgelegt.
  • volumes: Ein Volume wird für das Verzeichnis "/usr/share/nginx/html" in der Nginx-Containerinstanz gemappt, was bedeutet, dass der Inhalt des lokalen Verzeichnisses "./website" in das Nginx-HTML-Verzeichnis kopiert wird.
  • depends_on: Dieser Dienst hängt von "nginx-proxy-manager" ab, was bedeutet, dass "website" erst gestartet wird, wenn "nginx-proxy-manager" gestartet wurde.
  • restart: Der Container wird automatisch neu gestartet, es sei denn, der Benutzer stoppt ihn explizit (unless-stopped).
  • labels: Docker-Labels, die für den Container gesetzt werden. Hier wird ein Label hinzugefügt, um Watchtower zu aktivieren, was es ermöglicht, den Container automatisch zu aktualisieren, wenn neue Images verfügbar sind.

Dieses Beispiel demonstriert eine gängige Verwendung von Docker Compose, bei der ein Reverse Proxy (nginx-proxy-manager) mit einem anderen Dienst (website) kombiniert wird. Der Reverse Proxy ermöglicht es, mehrere Anwendungen auf demselben Hostsystem zu betreiben und den Verkehr basierend auf den Hostnamen oder anderen Kriterien zu Containern zu routen.

Webseite und Reverse-Proxy starten

Wir werden nun den Stack starten, indem wir folgenden Befehl eingeben während wir in dem Ordner sind, in welchem auch die docker-compose.yml liegt.

docker-compose up -d && docker-compose logs -f

Nachdem docker die Container erstellt hat und gestartet hat, beobachten wir auch gleich die Logs, die nun beim Start der Container entstehen. Wir können dies jederzeit duch CTRL-C beenden.

Im Ordner web sind nun zwei neue Unterordner entstanden. Einer für den Proxy und einer für den Webserver.

Der Webserver serviert nun die Webseiten im Unterordner website.

Hier legen wir die Datei index.html an, oder kopieren gleich alles hinein, was serviert werden soll.

Reverse-Proxy und Zertifikate konfigurieren

Hier werden wir nun bestimmen, welche URLs welchen Containern zugeordnet werden. Dazu nutzen wir aus Sicherheitsgründen einen ssh-Tunnel und loggen uns auf dem Server mit einem Zusatzparameter ein:

ssh -L 8081:localhost:81 root@mmtest.mywire.org

Damit leiten wir den Datenverkehr von Port 8081 unseres Rechners durch die ssh-Verbindung an Port 81 des Servers weiter. Wir rufen im Browser also die URL http://localhost:8081 auf, und landen auf der Konfigurationsseite des Reverse-Proxies. Dort loggen wir uns mit den Initial Login-Daten ein.

Email:    admin@example.com
Password: changeme

Wir legen natürlich sofort einen neuen Admin an und löschen den alten. Jetzt endlich legen wir die "URL" für den Webserver an:

In meinem Beispiel erreiche ich den Container,  dem wir den container_name website zugewiesen haben, unter der URL https://www.meister-security.de.

Im Tab SSL klicken wir nun auf Request a new SSL Certificate, um ein gültiges Zertifikat für die Webseite zu erhalten:

Unter der angegebenen Emailadresse werden wir vor dem Ablaufen des Zertifikates gewarnt. Sie sollte daher korrekt sein. Nach kurzer Zeit ist die Webseite online.

Security

Wenn man einen Dienst im Internet anbietet, lockt man natürlich auch ungebetene Gäste an, die vielleicht keine guten Absichten haben.

Schaue ich durch meine Logs, sehe ich oft solche Einträge:

Da vermutet wohl jemand Wordpress. Aber wie rufen diese Leute meinen Server auf? Schauen wir auch hier:

Wie man gut erkennen kann, scannen die meisten meinen Server, indem sie ihn direkt mit seiner IP-Adresse ansprechen. Es gab Versuche wp. voranzustellen. Ich selbst habe meinen Test-Webserver korrekt gerufen.

Was bekommt man zu sehen, wenn man nur die IP-Adresse ruft? Schauen wir kurz nach:

Das wollen wir natürlich vermeiden. Wo diese Einstellung vorgenommen wird, sehen wir uns genauer unter den Settings im nginx-proxy-manager an. Hier wird die Default-Site definiert, also was angezeigt werden soll, wenn nicht das gerufen wird, was konfiguriert wurde:

Ich konfiguriere hier normalerweise No Response (444). Ruft dann jemand meinen Server mit seiner IP-Adresse, sieht er folgendes:

Wenn ein Angreifer nun signalisiert bekommt, dass hier keine Webseite erreichbar ist, versucht er es meist nicht länger und zieht weiter.

Zusammenfassung

Wir haben einen Server irgendwo im Internet. Für diesen richten wir einen DNS-Namen ein - bevorzugt gleich mit allen Subdomains. Darauf einen Webserver, vor den wir einen Reverse-Proxy setzen. Dieser Proxy wird künftig weitere Webdienste verwalten...

Wie sich die Container selbst patchen und aktualisieren, wird in einem der nächsten Posts beschrieben.

Fazit

Die Errichtung dieser Grundlage stellt zweifellos den anspruchsvollsten Teil dar. Respekt gebührt jenen, die bis hierhin durchgehalten haben. Von nun an wird jedoch das Hinzufügen zusätzlicher Dienste ein Leichtes sein, wie wir in den nächsten Beiträgen sehen werden.