Na EdgeRouterze 4 uruchomiłem HAProxy, które rozdziela ruch HTTP i HTTPS między dwa serwery z Traefikiem. Dzięki temu usługi na obu serwerach są dostępne z Internetu na standardowych portach — bez dopisywania :444 do adresu.

Certyfikaty i routing do aplikacji pozostały w Traefiku. Router odpowiada tylko za wybór serwera: dla HTTP na podstawie nagłówka Host, a dla HTTPS na podstawie SNI.

Poniżej opisuję powód tej zmiany, docelowy przepływ ruchu, kompilację HAProxy dla EdgeOS i konfigurację całego rozwiązania.

Domeny i adresacja zostały zmienione. Sieć lokalna w przykładach to 10.10.10.0/24. Nazwy polityk firewalla, interfejsy i numery reguł odpowiadają mojemu wdrożeniu — nie są uniwersalną konfiguracją do wklejenia na dowolnym routerze.

Problem: dwa serwery i różne adresy usług

Początkowo publiczne porty 80 i 443 były przekierowane na pierwszy serwer, 10.10.10.251. Drugi serwer, docker1 pod adresem 10.10.10.157, miał osobne przekierowanie z publicznego portu 444 na swój port 443.

Dostęp do usługi na drugim serwerze wyglądał więc tak:

https://usluga1.example.com:444/

W sieci lokalnej korzystam z widoków DNS. Ta sama nazwa rozwiązuje się bezpośrednio na prywatny adres właściwego serwera. Lokalnie używałem więc:

https://usluga1.example.com/

Usługa działała, ale jej adres zależał od miejsca połączenia:

  • z Internetu: publiczny adres routera i port 444;
  • z LAN: prywatny adres serwera i standardowy port 443.

Komplikowało to również SSO z Authelią. Callbacki i adresy powrotne musiały uwzględniać wariant z portem albo bez niego.

Chciałem, żeby użytkownik zawsze korzystał z jednego adresu:

https://usluga1.example.com/

Bez zmiany portu w przeglądarce i bez przenoszenia wszystkich usług na jeden serwer.

Docelowa architektura

HAProxy przyjmuje ruch z Internetu na routerze i wybiera serwer na podstawie nazwy domeny.

Internet / publiczny DNS
         |
         | publiczny adres routera, TCP/80 i TCP/443
         v
EdgeRouter 4 / HAProxy
         |
         +-- wybrane domeny --> 10.10.10.157 --> Traefik --> usługi
         |
         +-- pozostałe ------> 10.10.10.251 --> Traefik --> usługi


LAN / lokalny widok DNS
         |
         +-- prywatny adres właściwego serwera --> Traefik --> usługi

Lokalny DNS nadal kieruje klientów bezpośrednio do serwerów. Standardowy ruch z LAN nie musi przechodzić przez HAProxy ani przez publiczny adres routera.

Z perspektywy użytkownika URL pozostaje jednak identyczny. Zmienia się odpowiedź DNS i ścieżka sieciowa, ale nie schemat, nazwa hosta ani port.

Podział odpowiedzialności

HAProxy i Traefik pełnią różne role:

ElementZadanie
HAProxy na EdgeRouterzeWybór serwera docelowego według domeny
Traefik na serwerachObsługa certyfikatów, terminacja TLS i routing do aplikacji
Lokalny DNSBezpośrednie kierowanie klientów LAN do właściwego serwera

Dla HTTP HAProxy odczytuje nagłówek Host.

Dla HTTPS odczytuje SNI z TLS ClientHello, a następnie przekazuje zaszyfrowany strumień do odpowiedniego Traefika. Router nie odszyfrowuje ruchu i nie przechowuje certyfikatów usług.

Między HAProxy a Traefikiem wykorzystuję dodatkowo PROXY protocol v2 do przekazywania informacji o połączeniu klienta.

Kompilacja HAProxy dla EdgeOS

Środowisko i architektura

Router działał na EdgeOS z następującym środowiskiem:

ElementWartość
SprzętEdgeRouter 4
Baza systemuDebian 9 Stretch
Kernel4.9.79-UBNT
glibc2.24
Architektura raportowana przez kernelmips64
Architektura userspace według dpkgmips

Istotne było rozróżnienie architektury kernela i userspace. Samo mips64 w wyniku polecenia sprawdzającego kernel nie oznaczało, że potrzebuję 64-bitowej binarki.

HAProxy 3.4.0 zbudowałem poza routerem, na Ubuntu 20.04, przy użyciu mips-linux-gnu-gcc 9.4.0.

Wynikiem był statycznie linkowany ELF 32-bit big-endian, MIPS32 rel2. Na EdgeOS nie instalowałem kompilatora ani nie aktualizowałem bibliotek systemowych.

Wersja 3.4.0 jest wersją wykorzystaną w tym wdrożeniu, a nie zaleceniem pomijania późniejszych poprawek.

Przygotowanie środowiska budowania

Na komputerze kompilującym zainstalowałem narzędzia:

sudo apt-get update

sudo apt-get install --no-install-recommends \
  make \
  gcc-mips-linux-gnu \
  binutils-mips-linux-gnu \
  libc6-dev-mips-cross \
  git

Miałem już klon repozytorium źródeł HAProxy w ~/haproxy. Z niego przygotowałem osobny katalog dla wybranego wydania:

cd ~/haproxy

git fetch --tags origin

git worktree add --detach ../haproxy-3.4.0 v3.4.0

cd ../haproxy-3.4.0

Jeżeli odtwarzasz ten proces od zera, najpierw przygotuj lokalny klon repozytorium źródeł. Powyższy fragment zaczyna się od istniejącego katalogu ~/haproxy.

Budowanie binarki

Końcowe polecenie kompilacji wyglądało następująco:

make SHELL='/bin/bash -o pipefail' clean

make -j2 \
  SHELL='/bin/bash -o pipefail' \
  TARGET=linux-glibc-legacy \
  CPU=generic \
  CC=mips-linux-gnu-gcc \
  LD=mips-linux-gnu-gcc \
  AR=mips-linux-gnu-ar \
  NM=mips-linux-gnu-nm \
  LDFLAGS='-static' \
  ADDLIB='-lpthread' \
  USE_THREAD=1 \
  USE_OPENSSL= \
  USE_LUA= \
  USE_PCRE= \
  USE_PCRE2= \
  USE_ZLIB= \
  USE_LIBCRYPT= \
  USE_CRYPT_H= \
  USE_DL= \
  USE_BACKTRACE= \
  USE_GETADDRINFO=

OpenSSL nie było potrzebne, ponieważ HAProxy nie kończy połączeń TLS. Router wybiera backend na podstawie ClientHello, a właściwy handshake i obsługa certyfikatu odbywają się w Traefiku.

Backendy wskazałem adresami IP. Statyczna glibc pozostawia ograniczenia związane z NSS, więc samo statyczne linkowanie nie oznacza pełnej niezależności każdej funkcji programu od bibliotek systemu docelowego.

Problemy podczas kompilacji

Do działającej binarki doprowadziły trzy istotne poprawki:

ProblemZmiana
make: command: Command not foundWymuszenie Basha przez SHELL='/bin/bash -o pipefail'
Brak symboli pthread_* podczas statycznego linkowaniaDodanie ADDLIB='-lpthread'
Odwołanie do thread_cpus_enabled_at_boot przy wyłączonych wątkachPrzebudowanie z USE_THREAD=1

Binarka ma obsługę wielu wątków, ale w konfiguracji uruchomieniowej ustawiłem nbthread 1. Zmiana tej wartości nie wymaga ponownej kompilacji.

Przeniesienie binarki na router

Wynikowy plik haproxy przeniosłem na router do katalogu domowego użytkownika.

Dalsze polecenia zakładają więc, że na EdgeRouterze istnieje plik:

$HOME/haproxy

Docelowo binarka trafi do /config/haproxy/bin/haproxy, razem z pozostałymi plikami tego wdrożenia.

Przygotowanie routera

Przeniesienie panelu administracyjnego

GUI EdgeOS zajmowało porty 80 i 443, na których miało nasłuchiwać HAProxy.

Panel przeniosłem na HTTP/8080 i HTTPS/8443:

configure

set service gui http-port 8080
set service gui https-port 8443

commit
save
exit

Po zmianie panel HTTPS jest dostępny pod adresem:

[https://10.10.10.254:8443/](https://10.10.10.254:8443/)

Portów administracyjnych nie dodałem do wyjątku WAN dla HAProxy.

Usunięcie starych przekierowań

Poprzedni układ opierał się na trzech regułach:

RegułaPoprzednia funkcja
2HTTP/80 → 10.10.10.251:80
3HTTPS/443 → 10.10.10.251:443, reguła obejmowała TCP i UDP
15HTTPS/444 → 10.10.10.157:443

Usunąłem je, ponieważ połączenia na portach 80 i 443 miał od tej pory przyjmować proces działający na samym routerze.

Dostęp dopuściłem w polityce local interfejsu WAN pppoe0. W mojej konfiguracji polityka ta nazywa się ibs-local.

configure

delete port-forward rule 2
delete port-forward rule 3
delete port-forward rule 15

set firewall name ibs-local rule 60 description 'HTTP HTTPS TCP to HAProxy'
set firewall name ibs-local rule 60 action accept
set firewall name ibs-local rule 60 protocol tcp
set firewall name ibs-local rule 60 destination port 80,443
set firewall name ibs-local rule 60 log disable

compare
commit
save
exit

Pozostałe przekierowania i reguły masquerade zostały bez zmian.

Usunięcie reguły 3 objęło również stare UDP/443. Nowa konfiguracja obsługuje TCP/80 i TCP/443, bez HTTP/3/QUIC.

Przeniesienie obsługi z DNAT do procesu na routerze zmienia miejsce filtrowania ruchu. W tym wdrożeniu potrzebny był dostęp w polityce local, a nie tylko reguły dotyczące ruchu przekazywanego przez router.

Dostęp do HAProxy z LAN

W polityce home-local, przypiętej do interfejsu VLAN HOME, roboczą regułę zastąpiłem dostępem do TCP/80 i TCP/443:

configure

delete firewall name home-local rule 25

set firewall name home-local rule 25 description 'HTTP HTTPS to HAProxy'
set firewall name home-local rule 25 action accept
set firewall name home-local rule 25 protocol tcp
set firewall name home-local rule 25 destination port 80,443
set firewall name home-local rule 25 log disable

commit
save
exit

Ta reguła umożliwia dostęp do samego HAProxy z LAN. Nie zmienia jednak podstawowego przepływu ruchu: lokalne widoki DNS nadal wskazują prywatne adresy serwerów, więc standardowe połączenia do usług omijają routerowe proxy.

Numery 25 i 60 są właściwe dla mojego wdrożenia. Przed zastosowaniem podobnej konfiguracji trzeba dopasować nazwy polityk, kolejność i numery reguł do własnego firewalla.

Połączenie z Traefikiem

Przygotowanie odbioru PROXY protocol

Po dodaniu HAProxy przed Traefikiem chciałem zachować informację o rzeczywistym adresie klienta. Między routerem a Traefikiem wykorzystałem do tego PROXY protocol v2.

Nie jest to terminacja TLS. Informacja o połączeniu jest przekazywana do Traefika, ale handshake TLS i certyfikat nadal obsługuje serwer docelowy.

Najpierw przygotowałem odbiór po stronie Traefika. W jego statycznej konfiguracji TOML uzupełniłem entrypointy:

[entryPoints]

  [entryPoints.web]
    address = ":80"

  [entryPoints.web.proxyProtocol]
    trustedIPs = ["10.10.10.254/32"]

  [entryPoints.websecure]
    address = ":443"

  [entryPoints.websecure.proxyProtocol]
    trustedIPs = ["10.10.10.254/32"]

To fragment do połączenia z istniejącą konfiguracją, a nie zamiennik całego pliku Traefika.

Pozostały routing, przekierowanie HTTP na HTTPS oraz obsługa certyfikatów zostały zachowane.

Traefik ufa informacjom PROXY protocol wyłącznie od routera, czyli 10.10.10.254. Jednocześnie entrypointy przyjmują zwykłe połączenia bez tego nagłówka, co pozwala zachować bezpośredni dostęp klientów z lokalnej sieci.

Po zmianie konfiguracji statycznej zrestartowałem Traefika. Dopiero później włączyłem wysyłanie PROXY protocol w HAProxy.

Konfiguracja HAProxy

Plik /config/haproxy.cfg zawiera osobne frontendy dla HTTP i HTTPS.

Wybrane domeny kieruję na 10.10.10.157. Pozostały ruch trafia do domyślnego backendu na 10.10.10.251.

global
    nbthread 1
    maxconn 256
    log stdout format raw local0

defaults
    log global
    timeout connect 5s
    timeout client  60s
    timeout server  60s

frontend https_in
    bind :443
    mode tcp
    option tcplog

    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }

    acl do_157 req.ssl_sni -i usluga1.example.com
    acl do_157 req.ssl_sni -i usluga2.example.com
    acl do_157 req.ssl_sni -i usluga3.example.com
    acl do_157 req.ssl_sni -i usluga4.example.com
    acl do_157 req.ssl_sni -i usluga5.example.com
    acl do_157 req.ssl_sni -i usluga6.example.com

    use_backend https_157 if do_157
    default_backend https_251

backend https_157
    mode tcp
    server srv157 10.10.10.157:443 check send-proxy-v2

backend https_251
    mode tcp
    server srv251 10.10.10.251:443 check send-proxy-v2

frontend http_in
    bind :80
    mode http
    option httplog

    acl do_157 hdr(host),lower,field(1,:) -m str usluga1.example.com
    acl do_157 hdr(host),lower,field(1,:) -m str usluga2.example.com
    acl do_157 hdr(host),lower,field(1,:) -m str usluga3.example.com
    acl do_157 hdr(host),lower,field(1,:) -m str usluga4.example.com
    acl do_157 hdr(host),lower,field(1,:) -m str usluga5.example.com
    acl do_157 hdr(host),lower,field(1,:) -m str usluga6.example.com

    use_backend http_157 if do_157
    default_backend http_251

backend http_157
    mode http
    server srv157 10.10.10.157:80 check send-proxy-v2

backend http_251
    mode http
    server srv251 10.10.10.251:80 check send-proxy-v2

Kilka szczegółów tej konfiguracji jest istotnych:

  • send-proxy-v2 włącza przekazywanie informacji o kliencie do Traefika. Odbiorca musi być wcześniej przygotowany do jego obsługi.
  • Na publicznych frontendach nie dodawałem accept-proxy.
  • default_backend obejmuje także połączenia, które nie pasują do listy domen.
  • HAProxy nie wymusza przekierowania HTTP na HTTPS. Pozostawiłem to Traefikowi.
  • bind :80 i bind :443 nie przypinają usługi wyłącznie do WAN. Dostęp do listenerów na lokalnych adresach IPv4 kontroluje firewall.
  • Obsługa IPv6 nie jest częścią tej konfiguracji.

Uruchamianie jako usługa

Rozmieszczenie plików

Binarkę, konfigurację i definicję usługi umieściłem w /config:

/config/haproxy/bin/haproxy
/config/haproxy.cfg
/config/haproxy/haproxy-edge.service

Skopiowaną wcześniej do katalogu domowego binarkę zainstalowałem następująco:

sudo mkdir -p /config/haproxy/bin

sudo install -o root -g root -m 0755 \
  "$HOME/haproxy" \
  /config/haproxy/bin/haproxy

sudo chown root:root /config/haproxy.cfg
sudo chmod 0644 /config/haproxy.cfg

Polecenia zakładają, że konfiguracja HAProxy jest już zapisana w /config/haproxy.cfg.

Definicja systemd

Plik /config/haproxy/haproxy-edge.service:

[Unit]
Description=HAProxy on EdgeRouter
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
ExecStartPre=/config/haproxy/bin/haproxy -c -f /config/haproxy.cfg
ExecStart=/config/haproxy/bin/haproxy -W -db -f /config/haproxy.cfg
ExecReload=/config/haproxy/bin/haproxy -c -f /config/haproxy.cfg
ExecReload=/bin/kill -USR2 $MAINPID
Restart=on-failure
RestartSec=5s
KillMode=mixed
KillSignal=SIGUSR1
TimeoutStopSec=60s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=haproxy-edge

[Install]
WantedBy=multi-user.target

HAProxy działa w trybie master-worker, na pierwszym planie pod kontrolą systemd.

Przed startem i przeładowaniem wykonywane jest sprawdzenie konfiguracji. Przeładowanie odbywa się przez sygnał USR2 do procesu master, a nieoczekiwane zakończenie procesu powoduje ponowienie startu.

Po zatrzymaniu instancji uruchamianej wcześniej ręcznie zarejestrowałem usługę:

sudo ln -sfn \
  /config/haproxy/haproxy-edge.service \
  /etc/systemd/system/haproxy-edge.service

sudo systemctl daemon-reload
sudo systemctl enable haproxy-edge.service
sudo systemctl start haproxy-edge.service

Odtwarzanie rejestracji po zmianie firmware

Pliki wdrożenia pozostają w /config, ale rejestracja usługi znajduje się również w /etc.

Do jej odtworzenia po zmianie obrazu firmware przygotowałem skrypt:

/config/scripts/post-config.d/90-haproxy-edge

Zawartość:

#!/bin/sh
set -eu

ln -sfn \
  /config/haproxy/haproxy-edge.service \
  /etc/systemd/system/haproxy-edge.service

systemctl daemon-reload
systemctl enable haproxy-edge.service
systemctl --no-block start haproxy-edge.service

Nadałem mu uprawnienia do wykonania:

sudo chmod 0755 \
  /config/scripts/post-config.d/90-haproxy-edge

Skrypt odtwarza dowiązanie do definicji usługi, przeładowuje konfigurację systemd i uruchamia HAProxy.

Nie zmienia to faktu, że własna binarka i hook startowy są rozszerzeniem utrzymywanym samodzielnie, a nie wbudowaną funkcją EdgeOS.

Bieżąca obsługa

Przeładowanie konfiguracji:

sudo systemctl reload haproxy-edge

Stan usługi:

sudo systemctl status haproxy-edge --no-pager -l

Podgląd logów:

sudo journalctl -u haproxy-edge -f

Restart:

sudo systemctl restart haproxy-edge

Efekt i ograniczenia

Po zmianie oba serwery korzystają z jednego publicznego wejścia i standardowych portów.

Wybrane domeny trafiają na 10.10.10.157, a reszta na dotychczasowy serwer 10.10.10.251. Z adresów usług zniknął port :444.

Lokalne widoki DNS nadal zapewniają bezpośredni dostęp po prywatnych adresach. URL usługi jest jednak identyczny w LAN i w Internecie, co uprościło również adresy powrotne SSO.

Certyfikaty oraz routing do aplikacji pozostały w Traefiku. EdgeRouter dostał warstwę wyboru serwera bez dokładania kolejnej maszyny na wejściu do sieci.

Rozwiązanie ma jednak jasno określone granice:

  • obsługuje TCP/80 i TCP/443, bez HTTP/3/QUIC;
  • nie obejmuje konfiguracji IPv6;
  • nie kończy TLS na routerze;
  • wymaga zgodnej konfiguracji PROXY protocol po stronie HAProxy i Traefika;
  • opiera się na własnej binarce i samodzielnie utrzymywanej integracji z EdgeOS;
  • przetwarza strumień na CPU routera — nie jest sprzętowo offloadowanym DNAT.

W moim przypadku najważniejszy efekt to jeden spójny adres każdej usługi, niezależnie od miejsca połączenia, przy zachowaniu dwóch osobnych serwerów i dotychczasowej obsługi aplikacji w Traefiku.