natuerlich
Goto Top

2x Traefik kaskadiert: RevProxy im DMZ + LAN (Docker) mit subdomains im LAN?

Ausgangslage: Erfolgreich traefik (Reverse-Proxy) auf Raspi 4 im DMZ zwischen FritzBox und Mikrotik eingerichtet (Ubuntu Server LTS | traefik pur ohne docker). Let's encrypt funktioniert für alle subdomains. Abgesichert erstmal mit DigestAuth. SSL Labs bescheinigt Rating "A".
Im internen Netz Ubuntu-Server (192.168.100.1) mit Docker (exemplarisch: grafana, esphome,...) und eigenem Zertifikat (CA bekannt gemacht in DMZ-Server und Browsern).

traefik_dmz: Funktioniert mit subdomains und direktem Redirect wunderbar (schreibt auch logs | /etc/traefik/traefik.yml und /etc/traefik/dynamic/config.yml --> siehe unten)
traefik_dmz --> Docker-Dienste (LAN)
https://grafana.meindomain.de --> https://192.168.100.1:3000
https://esphome.meindomain.de --> http://192.168.100.1:6052

Nachteil: Label können nicht sinnvoll genutzt werden.
Durchreichen von Docker-sock ins DMZ scheint mir unter Sicherheitsaspekten risikoreich und möchte ich vermeiden. Spricht ja nix gegen eine Kaskade.
Daher: traefik_int zusätzlich auf bestehendem Docker-Server installiert. Domains/subdomains im DMZ und LAN Docker-Server bekannt gemacht in hosts/DNS (hal-30, grafana.hal-30, esphome.hal-30 verweisen per ping auf allen Servern und Clients auf 192.168.100.1). Im Browser funktionieren "https://hal-30:3000" oder "http://hal-30:6052" "http://hal-30:8080" (für traefik_int Dashboard) wunderbar. Aber mit Port geht's am traefik_int natürlich vorbei.

Ziel: 2 traefik kaskadiert (DMZ für Let's encrypt und Sicherheit, INT für das geschmeidige Nutzen der Labels)
traefik_dmz --> traefik_int --> Docker-Dienste
https://grafana.meinedomain.de/ --> http://grafana.hal-30/ --> https://192.168.100.1:3000
https://esphome.meinedomain.de/ --> http://esphome.hal-30/ --> http://192.168.100.1:6052

Probleme:
  • traefik_int läuft (Dashboard zeigt Routers, Services,...), aber schreibt weder applog.log, noch access.log. Auch in "Portainer" schweigt das Log vom traefik_int --> docker-compose siehe unten
  • Aufruf der Dienste via treafik_int per Subdomain funktioniert nicht im Browser oder als redirect-Ziel aus dem treafik im DMZ. Weder "http://grafana.hal-30/", noch "http://esphome.hal-30/" --> "Bad Gateway"

Frage: Was übersehe ich und verhindert, dass traefik_int logs schreibt? Damit könnte man ja analysieren. Wie kann ich prüfen ob und wie die Anfragen traefik_int überhaupt erreichen?
Frage: Funktionieren subdomains im LAN? Wie richtet man das im traefik_int ein?

Danke für Eure Hilfe.

Anhang: docker-compose.yml von grafana und esphome jeweils um Label erweitert:
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.esphome.entrypoints=web"  
      - "traefik.http.routers.esphome.rule=Host(`esphome.hal-30`)"  
      - "traefik.http.services.esphome.loadbalancer.server.port=6052"  
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.grafana.tls=true"  
      - "traefik.http.routers.grafana.entrypoints=websecure"  
      - "traefik.http.routers.grafana.rule=Host(`grafana.hal-30`)"  
      - "traefik.http.services.grafana.loadbalancer.server.scheme=https"  
      - "traefik.http.services.grafana.loadbalancer.server.port=3000"  
Anhang: /app/traefik_int/docker-compose.yml
version: '3.3'  

services:

  traefik:
    image: traefik:v3
    container_name: traefik_int
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"  
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "80:80"  
      - "443:443"  
      - "8080:8080"  
    networks:
      - default
      - traefik
    command:
      # static traefik.yml replaced by commands:
      - "--api.insecure=true"  
      - "--api.dashboard=true"  
      - "--providers.docker=true"  
      - "--providers.docker.exposedbydefault=false"  
      - "--entrypoints.websecure.address=:443"  
      - "--entrypoints.websecure.http.tls=true"  
      - "--entrypoints.web.address=:80"  
      - "--log.filepath=/app/traefik_int/applog.log"  
      - "--log.format=json"  
      - "--log.level=DEBUG"  # INFO WARN DEBUG ERROR  
      - "--accesslog=true"  
      - "--accesslog.filepath=/app/traefik_int/access.log"  
      - "--accesslog.format=json"  
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.traefik.rule=PathPrefix(`/api`) || PathPrefix(`/dashboard`)"  
      - "traefik.http.routers.traefik.entrypoints=web"  
      - "traefik.http.routers.traefik.service=api@internal"  

networks:
  traefik:
    external: true

Anhang: DMZ /etc/traefik/traefik.yml
global:
  sendAnonymousUsage: false
  checkNewVersion: false

api:
  dashboard: false  # do not offer the dashboard of the DMZ traefik
  debug: false

log:
  filePath: "/etc/traefik/applog.log"  
  format: json
  level: "DEBUG"  # INFO WARN DEBUG  
  maxSize: 200
  maxBackups: 3

accessLog:
  filePath: "/etc/traefik/access.log"  
  format: json
#  bufferingSize: 100
#  filters:

providers:
  file:
    directory: "/etc/traefik/dynamic"  
    watch: true

entryPoints:
  web:
    address: ":80"  
    http:
      redirections:
        entryPoint:
          to: "websecure"  
          scheme: "https"  
          permanent: true
  websecure:
    address: ":443"  
#  traefik:
#    address: ":8080" 

certificatesResolvers:
  myLE:
    acme:
      email: "letsencrypt@meinedomain.de"  
      storage: "/etc/traefik/acme.json"  
      httpChallenge:
        # used during the challenge
        entryPoint: "web"  

Anhang: DMZ /etc/traefik/dynamic/config.yml
http:
  routers:
    grafana:
      rule: "Host(`grafana.meindomain.de`)"  
      entryPoints: "websecure"  
      tls:
        certResolver: "myLE"  
      middlewares:
      - "auth"  
      - "secRateLimit"  
      - "secHeaders"  
      service: "grafana"  

    esphome:
      rule: "Host(`esphome.meinedomain.de`)"  
      entryPoints: "websecure"  
      tls:
        certResolver: "myLE"  
      middlewares:
      - "auth"  
      - "secRateLimit"  
      - "secHeaders"  
      service: "esphome"  

  services:
    grafana:
      loadBalancer:
        servers:
          - url: "https://hal-30:3000"     # direct: OK  
#          - url: "http://grafana.hal-30/"           # via treafik_int: FAIL 

    esphome:
      loadBalancer:
        servers:
          - url: "http://hal-30:6052/"   # direct: OK  
#          - url: "http://esphome.hal-30"     # via treafik_int: FAIL 

  middlewares:

    auth:
      digestAuth:
        usersFile: "/etc/traefik/digestauthfile"  

    secHeaders:
      headers:
        browserXssFilter: true
        contentTypeNosniff: true
        frameDeny: true
        sslRedirect: true
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 15768000

    secRateLimit:
      rateLimit:
        average: 50
        burst: 50

Content-ID: 669743

Url: https://administrator.de/forum/2x-traefik-kaskadiert-revproxy-im-dmz-lan-docker-mit-subdomains-im-lan-669743.html

Ausgedruckt am: 05.02.2025 um 05:02 Uhr

natuerlich
natuerlich 19.01.2025 um 17:21:41 Uhr
Goto Top
Ein Update von mir selber - nach derzeit 1211 pagehits face-smile

Funktioniert:

Die Gründe für Fehlfunktionen waren vielfältig (so weit ich mich über die Wochen noch erinnere):
  • interne subdomains müssen im RevProxy UND MIkrotik (innerer Router) UND pihole gesetzt sein - dann funktioniert auch DNS korrekt
192.168.100.1 grafana.hal-30
192.168.100.1 esphome.hal-30
  • Volume von Container treafik_int war nicht korrekt auf den Host umgeleitet für die logs.
  • FW im Mikrotik muss vom RevProxy FORWARD auf alle Ziele im inneren Netz haben
  • Eigene CA muss dem RevPorxy (/etc/ssl/certs/ca-certificates.crt) bekannt gemacht werden, weil sonst jeder https Zugriff nach innen mit nicht sprechenden Fehlermeldungen schief läuft.
  • Da traefik_int ein eigenes Docker-Netzwerk nimmt (mit anderen IP z.B. 172.17.33.0) passt das Zertifikat des Docker Servers nicht fehlerfrei, daher ist nötig "--serversTransport.insecureSkipVerify=true"
  • Manche Änderungend der Konfiguration von treafik EXT und INT waren nötig. Weiter unten alle gesammelten Dateien

Darüber hinaus ein paar Absicherungen des traefik EXT:
  • Auto-Update
  • PW in traefik EXT via DigestAuth
  • secRateLimit - keine Ahnung, wie wirksam und ob Parameter sinnvoll gewählt wurden, aber scheint schon sinnvoll gegen BrutForce/DDoS
  • GeoBlocker-Plugin mit positivliste auf sehr wenige Länder (für meine Zwecke ausreichend)
  • OFFEN: fail2ban-Plugin war völlig sinnlos, weil jede normale Navigation/Benutzung der Applikationen (z.B. normale Navigation in grafana) gezählt wurden.
> FRAGE: Habe ich das nur falsch eingerichtet? Wie macht man das richtig, damit nur der primär-Aufruf gezählt wird und die Navigation nicht mehr? Man möchte ja eigentlich DDoS oder BrutForce damit simpel abwehren.
  • CrowdSec würde das vielleicht besser machen, war mir aber zu aufwändig. Ich möchte einfach. Fail2ban sah danach eigentlich aus.
  • OFFEN: Firewall auf RevPorxy aktivieren und auf die wenige Ports beschränken 22, 443, 80, 8080
  • OFFEN: Prüfen, ob OWASP Modsecurity Plugin sinnvoll ist. Meinungen?
  • OFFEN: Prüfen, ob Authelia Plugin für 2FA/TOTP sinnvoll ist. Passkeys via Apple habe ich leider noch nicht als Plugin gefunden.
  • OFFEN: Cloudflare in Betracht ziehen, wenn die Logs auffällig sind.
  • OFFEN: Für manche (kritische) Anwendungen ziehe ich in Betracht, noch mTLS = Client Certificates einzubauen. Ist sicherer für Apps, die nur ich brauche und Schaden anrichten können.

Funktioniert nicht:
  • Kaskade geht weiterhin NICHT: traefik EXT ruft als Ziel traefik INT und läuft auf Fehler 403. Und ich habe keine Idee, warum nicht:
traefik_dmz --> traefik_int --> Docker-Dienste
https://grafana.meinedomain.de/ --> http://grafana.hal-30/ --> https://192.168.100.1:3000
https://esphome.meinedomain.de/ --> http://esphome.hal-30/ --> http://192.168.100.1:6052

Kurz: traefik EXT und traefik INT jedes für sich funktioniert prima, aber leider nicht kaskadiert. Hier also das Ergebnis für alle als Leasons Learned - möge es hilfreich sein für den ein oder anderen. Ein SSLabs-Rating A ist damit sicher und A+ möglich. Eine gewisse Sicherheit sollte auch gegeben sein und kann weiter erhöht werden.

>> traefik INT auf Docker Server <<

Anhang: docker-compose.yml für http und https (z.B. esphome und grafana) jeweils um Label erweitert. Dabei ist der INTERNE Port (Docker: rechts vom Doppelpunkt) zu wählen, nicht der exponierte!:
    networks:
      - traefik
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.<APP>.entrypoints=web"  
      - "traefik.http.routers.<APP>.rule=Host(`<SUBDOMAIN>.hal-30`)"  
      - "traefik.http.services.<APP>.loadbalancer.server.port=<PORT INTERN!>"  

networks:
  traefik:
    external: true
    networks:
      - traefik
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.<APP>.entrypoints=websecure"  
      - "traefik.http.routers.<APP>.tls=true"  
      - "traefik.http.routers.<APP>.rule=Host(`<SUBDOMAIN>.hal-30`)"  
      - "traefik.http.services.<APP>.loadbalancer.server.port=443"  
      - "traefik.http.services.<APP>.loadbalancer.server.scheme=https"  

networks:
  traefik:
    external: true
Anhang: traefik-dynamic.yml
tls:
  stores:
    default:
      defaultCertificate:
        certFile: /etc/ssl/certs/cert.pem
        keyFile: /etc/ssl/private/key.pem
Anhang: docker-compose.yml
version: '3.3'  

services:

  traefik:
    image: traefik:v3
    container_name: traefik_int
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"  
      - /etc/localtime:/etc/localtime:ro
      - /etc/ssl/meineca/certs/servercert.192.168.100.1.pem:/etc/ssl/certs/cert.pem:ro
      - /etc/ssl/meineca/private/serverkey.192.168.100.1.pem:/etc/ssl/private/key.pem:ro
      - /app/traefik_int:/var/log
      - /app/traefik_int/traefik-dynamic.yml:/traefik-dynamic.yml
    ports:
      - "80:80"  
      - "443:443"  
      - "8080:8080"  
    networks:
      - default
      - traefik
    command:
      # static traefik.yml replaced by commands:
      - "--api.insecure=true"  
      - "--api.dashboard=true"  
      - "--providers.docker=true"  
      - "--providers.docker.endpoint=unix:///var/run/docker.sock"  
      - "--providers.docker.exposedbydefault=false"  
      - "--providers.docker.network=traefik"  
      - "--serversTransport.insecureSkipVerify=true"  
      - "--providers.file.filename=/traefik-dynamic.yml"  
      - "--entrypoints.websecure.address=:443"  
      - "--entrypoints.websecure.http.tls=true"  
      - "--entrypoints.web.address=:80"  
      - "--log.filepath=/var/log/applog.log"  
      - "--log.format=json"  
      - "--log.level=INFO"  # INFO WARN DEBUG ERROR  
      - "--log.maxsize=100"  # in MB  
      - "--log.maxbackups=3"  
      - "--accesslog=true"  
      - "--accesslog.filepath=/var/log/access.log"  
      - "--accesslog.format=json"  
      - "--accesslog.bufferingSize=100"  
      - "--accesslog.filters.statusCodes=200,300-302"  
      - "--accesslog.filters.retryAttempts"  
      - "--accesslog.filters.minDuration=10ms"  
      - "--accesslog.fields.defaultMode=drop"  
      - "--accesslog.fields.names.StartLocal=keep"  
      - "--accesslog.fields.names.TLSVersion=keep"  
      - "--accesslog.fields.names.RequestAddr=keep"  
      - "--accesslog.fields.names.RequestPath=keep"  
      - "--accesslog.fields.names.RequestHost=keep"  
      - "--accesslog.fields.names.ClientAddr=keep"  
      - "--accesslog.fields.names.ServiceURL=keep"  
      - "--accesslog.fields.names.DownstreamStatus=keep"  
      - "--accesslog.fields.names.OriginStatus=keep"  
      - "--accesslog.fields.headers.defaultMode=drop"  
      - "--accesslog.fields.headers.names.User-Agent=drop"  
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.traefik.rule=PathPrefix(`/api`) || PathPrefix(`/dashboard`)"  
      - "traefik.http.routers.traefik.entrypoints=web"  
      - "traefik.http.routers.traefik.service=api@internal"  

  whoami:
    # A container that exposes an API to show its IP address
    image: traefik/whoami
    networks:
      - traefik
    labels:
      - "traefik.enable=true"  
      - "traefik.http.routers.whoami.entrypoints=web"  
      - "traefik.http.routers.whoami.rule=Host(`whoami.hal-30`)"  

networks:
  traefik:
    external: true

>> traefik EXT im DMZ <<
Anhang: /etc/traefik/traefik.yml
    global:
      sendAnonymousUsage: false
      checkNewVersion: false

    api:
      dashboard: false  # do not offer the dashboard of the DMZ traefik
      debug: false

    providers:
      file:
        directory: "/etc/traefik/dynamic"  
        watch: true
    # config.yml: providers + services
    # middleware.yml: middlewares
    # tls.yml

    entryPoints:
      web:
        address: ":80"  
        http:
          redirections:
            entryPoint:
              to: "websecure"  
              scheme: "https"  
              permanent: true
      websecure:
        address: ":443"  
    #  traefik:
    #    address: ":8080" 

    certificatesResolvers:
      myLE:
        acme:
          email: "letsencrypt@meinedomain.de"  
          storage: "/etc/traefik/acme.json"  
          httpChallenge:
            # used during the challenge
            entryPoint: "web"  

    experimental:
      plugins:
        traefik-plugin-rewrite-headers:
          moduleName: "github.com/XciD/traefik-plugin-rewrite-headers"  
          version: "v0.0.4"  
     
    #    fail2ban:
    #      moduleName: "github.com/tomMoulard/fail2ban" 
    #      version: "v0.8.3" 

        geoblock:
          moduleName: "github.com/PascalMinder/geoblock"  
          version: "v0.2.8"  

    log:
      filePath: "/etc/traefik/applog.log"  
      format: "json"  
      level: "INFO"  # INFO WARN DEBUG ERROR  
      maxSize: 100
      maxBackups: 3

    accessLog:
      filePath: "/etc/traefik/access.log"  
      format: json
      bufferingSize: 100
      filters:
        statusCodes:
          - "200"  
          - "300-302"  
        retryAttempts: true
        minDuration: "10ms"  
      fields:
        defaultMode: "drop"  
        names:
          StartLocal: "keep"  
          TLSVersion: "keep"  
Anhang: /etc/traefik/dynamic/config.yml
    http:
      routers:

        grafana:
          rule: "Host(`grafana.meinedomain.de`)"  
          entryPoints: "websecure"  
          tls:
            certResolver: "myLE"  
          middlewares:
          - "default_PW_auth"  
          service: "grafana"  

        esphome:
          rule: "Host(`esphome.meinedomain.de`)"  
          entryPoints: "websecure"  
          tls:
            certResolver: "myLE"  
          middlewares:
          - "default_PW_auth"  
          service: "esphome"  

      services:
        grafana:
          loadBalancer:
            servers:
              - url: "https://hal-30:9920"     		# direct: OK  
    #          - url: "https://grafana.hal-30/"          # via treafik_int: FAIL 
        esphome:
          loadBalancer:
            servers:
              - url: "http://hal-30:6052"     		# direct: OK  
    #          - url: "http://esphome.hal-30/" 		 # via treafik_int: FAIL 
Anhang: /etc/traefik/dynamic/middlewares.yml
    http:
      middlewares:

        default_PW_auth:
          chain:
            middlewares:
              - "PW_auth"  
              - "secHeaders"  
              - "secRateLimit"  
    #          - "my-fail2ban"  # funktioniert nicht, weil jede normale Navigation auf Zielseite mit gezählt wird -> disabled 
              - "my-geoblock"  

        PW_auth:
          digestAuth:
            usersFile: "/etc/traefik/digestauthfile"  

        secHeaders:
          headers:
            browserXssFilter: true
            contentTypeNosniff: true
            frameDeny: true
            forceSTSHeader: true
            sslRedirect: true
            stsIncludeSubdomains: true
            stsPreload: true
            stsSeconds: 15768000
            customFrameOptionsValue: "SAMEORIGIN"  

        secRateLimit:
          rateLimit:
            average: 50
            burst: 50

        my-geoblock:
          plugin:
            geoblock:
              allowLocalRequests: "true"  
              allowUnknownCountries: "false"  
              api: "https://get.geojs.io/v1/ip/country/{ip}"  
              apiTimeoutMs: "150"  
              cacheSize: "15"  
              forceMonthlyUpdate: "true"  
              logAllowedRequests: "true"  
              logApiRequests: "true"  
              logLocalRequests: "false"  
              silentStartUp: "false"  
              unknownCountryApiResponse: "nil"  
              blackListMode: "false"  
              addCountryHeader: "false"  
              countries:
                - DE
                - CH
                - AT
    
    #    my-fail2ban:
    #      plugin:
    #        fail2ban:
    #          logLevel: "DEBUG" 
    ##          allowlist:
    ##            ip: ::1,127.0.0.1
    ##          denylist:
    ##            ip: 192.168.0.0/24
    #          rules:
    #            bantime: "15s" 
    #            enabled: "true" 
    #            findtime: "5s" 
    #            maxretry: "10" 
    #            statuscode: "400,401,403-499" 
Anhang: /etc/traefik/dynamic/tls.yml
tls:
  options:
    default:
      minVersion: "VersionTLS12"  
      cipherSuites:
        - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"  
        - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"  
        - "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"  
        - "TLS_AES_128_GCM_SHA256"  
        - "TLS_AES_256_GCM_SHA384"  
        - "TLS_CHACHA20_POLY1305_SHA256"  
      curvePreferences:
        - "CurveP521"  
        - "CurveP384"  
      sniStrict: true
natuerlich
natuerlich 19.01.2025 aktualisiert um 22:46:33 Uhr
Goto Top
Es bleiben folgende Fragen und freue mich über Eure Meinungen und Expertise:

  • Wie funktioniert die Kaskade traefik EXT -> traefik INT? Bzw. was verhindert es gerade?
  • Was mache ich bei fail2ban falsch, dass jede Navigation in der Zielapp mit gezählt wird? (siehe in vorheriger Nachricht). Oder erfüllt ""secRateLimit" bereits einen guten Schutz gegen DDoS und BrutForce?
  • PW mit DigestAuth mit Usergruppen: Wenn grafana für user A und B erlaubt sein sollen und esphome für user Z, dann brauche ich zwei digestauthfiles? Oder kann ich in einem file via realm A und B mit realm "grafana" und user Z mit realm "esphome" im selben digestauthfile ablegen und dann in der middleware sagen, prüfe grafana gegen realm "grafana" und für esphome gegen realm "esphome"? Aber mir schient, realm hat einen anderen Zweck als eine usergruppe...
  • Ich möchte eigentlich statt subdomains über subpaths verwenden. Weil die subdomains öffentlich sind und es potentiellen Angreifern leichter machen, Lücken bekannter Apps anzugreifen. Mit subpaths ist es weniger augenfällig und anstrengender und damit hoffentlich ein wenig sicherer. Auf der anderen Seite ist subpath immer komplizierter, als sudomain, weil die Apps eine abweichenden root/base URL anbieten können müssen - oder es wird schwierig; mindestens für die Apps die das nicht anbieten. Manche tun das (grafana z.B.), andere nicht (esphome z.B.).
Hat jemand eine sauber laufende Lösung mit subpaths laufen? Meine Versuche in traefik EXT waren "irgendwie" erfolgreich, aber nicht stabil. Zum Teil kam die PW-EIngabe von traefik mehrfach und dann Proxy auf falsche Ziel, etc. Nicht stabil auf jeden Fall. Oder lohnt das den Aufwand nicht und bleibt besser bei subdomains?