Reverzní proxy a Nette router

Rndoom04
Člen | 79
+
0
-

Ahoj,
dokázal by mi někdo poradit? Zatím je to 1:0 pro mou neznalost a nevím kde už dělám chybu.

  1. Hlavní doména domainA.tld má modul /w. Takže kdybych načetl domainA.tld/w/identifikator, tak to funguje dobře.
  2. K tomu se generuje identifikator.domainA.tld, který se podívá do routy a upřednostní tento zápis.
  3. K dispozici ale může být i zcela jiná doména – domainB.tld, která má jediný cíl a to načíst modul W.

Nastavil jsem proxy takto:

SSLProxyEngine On
SSLProxyVerify none
SSLProxyCheckPeerCN Off
SSLProxyCheckPeerName Off
ProxyPreserveHost Off
ProxyPass / https://www.domainA.cz/
ProxyPassReverse / https://www.domainA.cz/

RequestHeader set X-Forwarded-Host "www.domainB.cz"
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"

RequestHeader set Host "www.domainA.cz"

To funguje relativně dobře, ale problém je, že se mi to redirectne na zápis se subdoménou: identifikator.domainA.tld. V případě, že filterOut upravím, tak dochází k nekonečné smyčce přesměrování. Takže to false v té podmínce je teď pro to, aby to fungovalo nějak, ale určitě to není cílem. Cílem je, že www.domainB.tld načte modul /w/identifikator. Propojené to mám databází.

<?php

declare(strict_types=1);

namespace App\Core;

use Nette\Application\Routers\RouteList;
use Nette\Database\Explorer;

final class RouterFactory {

    public function __construct(private Explorer $db,) { ; }

    public function createRouter(): RouteList {
        $router = new RouteList;

        /* ===== 1. Dynamické domény a subdomény pro W ===== */
        $router[] = $w = new RouteList('W');

        $w->addRoute('//<host>/[<presenter>][/<action>]', [
            'presenter' => 'Home',
            'action' => 'default',
            '' => [
                'filterOut' => function (array $params) {
                    if (!isset($params['url'])) {
                        return null;
                    }

                    $url = $params['url'];
                    $row = $this->db->fetch('SELECT vlastni_domena FROM websites WHERE url = ?', $url);

                    if (false && $row && $row->vlastni_domena) {
// Úmyslně false, aby to zatím nějak fungovalo, ale jinak tady je způsoben ten nekonečný redirect
                        $params['host'] = $row->vlastni_domena;
                    } else {
                        $currentHost = $_SERVER['HTTP_X_FORWARDED_HOST']
                            ?? $_SERVER['HTTP_HOST']
                            ?? 'domainA.local';
                        $currentHost = trim(explode(',', $currentHost)[0]);
                        $currentHost = explode(':', $currentHost)[0];

                        $baseDomain = str_ends_with($currentHost, '.cz') ? 'domainA.cz' : 'domainA.local';
                        $params['host'] = $url . '.' . $baseDomain;
                    }

                    unset($params['url']);
                    return $params;
                },
                'filterIn' => function (array $params) {
                    $forwardedHost = isset($_SERVER['HTTP_X_FORWARDED_HOST'])
                        ? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_HOST'])[0])
                        : null;

                    // Pokud X-Forwarded-Host je hlavní doména, tato routa se netýká
                    $mainDomains = [
                        'domainA.local',
                        'domainA.cz',
                        'www.domainA.cz',
                        '192.168.100.204',
                    ];

                    if ($forwardedHost && in_array($forwardedHost, $mainDomains, true)) {
                        return null;
                    }

                    // Přepíšeme host skutečnou doménou klienta
                    if ($forwardedHost) {
                        $params['host'] = $forwardedHost;
                    }

                    $hostWithPort = $params['host'] ?? '';
                    if ($hostWithPort === '')
                        return null;

                    $host = explode(':', $hostWithPort)[0];

                    if (in_array($host, $mainDomains, true)) {
                        return null;
                    }

                    // A) Kontrola vlastní domény
                    $row = $this->db->fetch(
                        'SELECT url FROM websites WHERE vlastni_domena = ? AND zverejneno = 1 AND platne_do > NOW()',
                        $host,
                    );

                    if ($row) {
                        $params['url'] = $row->url;
                        return $params;
                    }

                    // B) Kontrola subdomény
                    foreach (['domainA.local', 'domainA.cz'] as $main) {
                        if (str_ends_with($host, '.' . $main)) {
                            $slug = substr($host, 0, -strlen('.' . $main));
                            $row = $this->db->fetch(
                                'SELECT url FROM websites WHERE url = ? AND platne_do > NOW()',
                                $slug,
                            );
                            if ($row) {
                                $params['url'] = $row->url;
                                return $params;
                            }
                        }
                    }

                    // C) Neznámá doména – redirect na hlavní web
                    $currentHost = $forwardedHost ?? ($_SERVER['HTTP_HOST'] ?? '');
                    $isLocal = str_contains($currentHost, '.local') || str_contains($currentHost, '192.168.');
                    $targetMain = $isLocal ? $currentHost : 'domainA.cz';

                    if ($host === '192.168.100.204' || $host === $targetMain || $host === "www.$targetMain") {
                        return null;
                    }

                    header("Location: https://$targetMain/domena-nenapojena", true, 302);
                    exit;
                },
            ],
        ]);

        /* ===== 2. Výchozí doména (Administrace, Cron, atd.) ===== */
        $router[] = $admin = new RouteList('Admin');
        $admin->addRoute('admin/<presenter>/<action>[/<id>]', 'Home:default');

        $router[] = $cron = new RouteList('Cron');
        $cron->addRoute('cron/<presenter>/<action>', 'Home:default');

        $router[] = $app = new RouteList('App');
        $app->addRoute('app/plan/dekujeme/<orderVS>', 'Plan:dekujeme');
        $app->addRoute('app/<presenter>[/<action>][/<id>]', 'Home:default');

        $router[] = $webhook = new RouteList('Webhook');
        $webhook->addRoute('webhook/<presenter>/<action>[/<id>]', 'Home:default');

        $router[] = $error = new RouteList('Error');
        $error->addRoute('error/<presenter>/<action>', 'Error4xx:default');

        $router[] = $front = new RouteList('Front');
        $front->addRoute('sitemap.xml', 'Sitemap:default');
        $front->addRoute('stahnout[/<cloudJob>]', 'Stahnout:default');
        $front->addRoute('<presenter>/<action>[/<id>]', 'Home:default');

        return $router;
    }
}

Budu moc rád za popostrčení, AI neporadilo – kromě přepsání práv k souborům na serveru nebo nějakých dalších nesmyslů. Ale nemyslím si, že bych chtěl nějak moc. :) Předem děkuji. :)

Editoval Rndoom04 (30. 4. 5:03)

m.brecher
Člen | 920
+
+1
-

@Rndoom04

Ale nemyslím si, že bych chtěl nějak moc.

Nette Router umí potrápit. Vedle match kaskády, která může dělat nečekané věci je zde také auto-canonicalizace – přesměrování na kanonickou doménu, kterou provádí ne Router ale Presenter. Proxy přepisuje host a nette presenter o tom zřejmě neví a pak když kontroluje zda je url kanonická mu může vyjít že není. Zkusil bych auto-canonicalizaci vypnout, třeba to problém vyřeší:

abstract class BasePresenter extends Presenter
{
    protected bool $autoCanonicalize = false;

    // .....
}

m.brecher
Člen | 920
+
+1
-

@Rndoom04

Napadlo mě, že pravděpodobně chceš zprovoznit SaaS multi-tenant systém pro provoz více webových stránek na jednom serveru v jedné Nette aplikaci, s možností provozu stránek na subdoméně hlavní domény main.com, nebo na doméně zákazníka client.com. Weby by se mapovaly na url dvěma způsoby:

  • identifikator.main.cz # vývojová verze
  • client.cz # vlastní doména zákazníka, po dokončení vývoje

Použití Proxy je zde nevhodné ze dvou důvodů:

a) musel by jsi pro každého nového zákazníka ručně přepisovat konfiguraci Apache,
b) nemohl by jsi použít užitečnou autocanonicalizaci Nette.

Proxy zde nemá žádnou smysluplnou funkci – zahodit!

Nastavení VirtualHost Apache

Místo proxy nakonfiguruj v Apache tzv. catch-all VirtualHost, která namapuje jakékoliv domény na Nette aplikaci a Router Nette to zvládne.

<VirtualHost *:80>
    ServerName domainA.local
    ServerAlias *.domainA.local
    ServerAlias *

    DocumentRoot /cesta/k/nette/public

    <Directory /cesta/k/nette/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Routy – filterOut

Problém dělal filterOut pro klienty s vlastní doménou, generuje linky client.cz nebo identificator.main.cz podle databáze. Při kliknutí na client.cz link nette presenter kontroluje zda je url kanonické, a protože je v cestě proxy, které url přepisuje, vyhodnotí kanonizaci špatně a přesměruje kam nechceš.

Po vyřazení přepisování domény v proxy bude Tvůj kód fungovat správně, stačí vyřadit ten dočasný false &&. A nemusíš vypínat autoCanonicalizaci !

Dej feedback

Zajímalo by mě, zda odstranění proxy problém vyřešilo, nebo jsem problém špatně pochopil. Dík!

Editoval m.brecher (5. 5. 0:54)

m.brecher
Člen | 920
+
+1
-

@Rndoom04

Filtry – filterOut i filterIn používají proxy http hlavičky, které po odstranění proxy nebudou k dipozici. Router použije strategie vyřešení url podle HTTP_HOST, což je lepší, protoženebude docházet k přesměrování.

filterIn strategie

Dostaneme host z URL. Postupně zkusíme:

Je to hlavní doména? → ignoruj, ať to zpracují jiné routy
Je to vlastní doména zákazníka? → hledej v DB podle vlastni_domena
Je to subdoména hlavní domény? → hledej v DB podle url slug
Nic nenašel? → redirect na stránku „doména nenapojena“

filterOut strategie

Dostaneme url slug webu. Potřebujeme vygenerovat kanonickou URL:

Má web vlastní doménu v DB? → kanonická je client.com
Nemá vlastní doménu? → kanonická je identifikator.main.com
Lokální nebo produkční prostředí poznáme ze skutečného HTTP_HOST – bez proxy je vždy spolehlivý

Routa

$w->addRoute('//<host>/[<presenter>][/<action>]', [
    'presenter' => 'Home',
    'action'    => 'default',
    ''          => [

        'filterIn' => function (array $params): ?array {

            $host = explode(':', $params['host'] ?? '')[0]; // odstraní port
            if ($host === '') return null;

            $mainDomains = [
                'main.local',
                'main.cz',
                'www.main.cz',
            ];

            // 1. Hlavní doména – necháme zpracovat jiné routy
            if (in_array($host, $mainDomains, true)) {
                return null;
            }

            // 2. Vlastní doména zákazníka – lookup podle vlastni_domena
            $row = $this->db->fetch(
                'SELECT url FROM websites
                 WHERE vlastni_domena = ?
                   AND zverejneno = 1
                   AND platne_do > NOW()',
                $host,
            );
            if ($row) {
                $params['url'] = $row->url;
                return $params;
            }

            // 3. Subdoména hlavní domény – lookup podle url slug
            foreach (['main.local', 'main.cz'] as $baseDomain) {
                if (str_ends_with($host, '.' . $baseDomain)) {
                    $slug = substr($host, 0, -strlen('.' . $baseDomain));
                    $row = $this->db->fetch(
                        'SELECT url FROM websites
                         WHERE url = ?
                           AND platne_do > NOW()',
                        $slug,
                    );
                    if ($row) {
                        $params['url'] = $row->url;
                        return $params;
                    }
                }
            }

            // 4. Neznámá doména – redirect na hlavní web
            $isLocal = str_contains($host, '.local');
            $target  = $isLocal ? 'main.local' : 'main.cz';
            header("Location: https://$target/domena-nenapojena", true, 302);
            exit;
        },

        'filterOut' => function (array $params): ?array {

            if (!isset($params['url'])) {
                return null;
            }

            $url = $params['url'];

            // Pozná lokální prostředí podle skutečného HTTP_HOST
            $currentHost = explode(':', $_SERVER['HTTP_HOST'] ?? '')[0];
            $isLocal     = str_ends_with($currentHost, '.local');
            $baseDomain  = $isLocal ? 'main.local' : 'main.cz';

            // Má zákazník vlastní doménu?
            $row = $this->db->fetch(
                'SELECT vlastni_domena FROM websites WHERE url = ?',
                $url,
            );

            if ($row && $row->vlastni_domena) {
                // Kanonická = vlastní doména zákazníka
                $params['host'] = $row->vlastni_domena;
            } else {
                // Kanonická = subdoména hlavní domény
                $params['host'] = $url . '.' . $baseDomain;
            }

            unset($params['url']);
            return $params;
        },
    ],
]);

Editoval m.brecher (5. 5. 1:05)

Rndoom04
Člen | 79
+
0
-

Ahoj,
děkuji za odpovědi. Díval jsem se průběžně, ale už jsem nezvládl z časových důvodů odepsat. Vyřešil jsem to metodou, kdy jsem si vytvořil custom PHP-FPM pool a do něj mapuji jednotlivé custom domény. Jelikož běžím na ISPconfig, tak to pro mě znamená jen vytvořit projekt, zkopírovat konfiguraci a vyřešeno. Zatím mi to dostačuje, uvidím jak mě to bude štvát v budoucnu. :)

Takže z části je to řešeno, jak píšeš. Moc děkuji.