從零開始的Laravel生活(部署篇-踩坑實錄)

前言

自從摸到 VM 的機器後又過了一段時間,就和學校電算申請了一個虛擬路徑用來連線到 VM 的主機。這個虛擬路徑服務除了主要功能以外,還可以幫忙上 SSL,省去我們單位自己維護 ssl 憑證和 domain 的麻煩。雖然這個服務聽起來相當方便,但要把原本的主機改成用虛擬路徑服務連線絕對不是簡單的一件事,何況這個主機上面同時架了三個網站,還要再根據不同的 request url 導向到不同的本機網站上面去,因此主機為了要配合虛擬路徑服務,又再加了一個 Reverse Proxy。

這次的踩坑是 Laravel 在虛擬路徑上支援不完整,也花了很久的時間找出 Bug 在哪邊並且修復。

正文

問題描述

連上網站後虛擬路徑會被改寫成https://domain/,但是實際上提出的 request 是https://domain/to/project,導致了嚴重的路徑錯誤問題。經過觀察後發現前端路由(Ziggy)的 url 變數是http://localhost:8080/,可以先猜測是 reverse proxy 導致原本 request 的資訊遺失,原本的後端伺服器只能收到 reverse proxy 所送請求的資訊。

另一個問題是發出 xhr 請求以後會被 CORS policy 擋掉,初期猜測是因為請求路徑錯誤,提出的 request 是發給 http 的,以致被瀏覽器的 CORS policy 擋掉。後來發現問題其實在於 reverse proxy 的設定。

解決歷程

設定 Laravel Trust Proxy

一開始先試試看用TrustProxies.php裡面的設定來解決遺失的資訊。

app/Http/Middleware/TrustProxies.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

namespace App\Http\Middleware;

use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies = '*'; // allow all proxies for testing

/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

之後調整 NGINX 的 reverse proxy 設定 (節錄)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server {
listen 80;
listen [::]:80;

server_name _;

location /to/project/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host domain;
proxy_set_header X-Forwarded-Port 443;
proxy_pass http://localhost:8080/;
}
}

重啟 NGINX 後會發現能抓到上面這些資訊了,可是問題路徑被改寫的問題依舊存在,應該是能算是解決一小部分。

設定 X-Forwarded-Prefix

經過搜尋後,發現 X-Forwarded-Prefix 這個 Header 可以達成目前虛擬路徑的需求,便先嘗試加入這個 Header 試試看。

NGINX 設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
listen 80;
listen [::]:80;

server_name _;

location /to/project/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host domain;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Prefix /to/project/;
proxy_pass http://localhost:8080/;
}
}

app/Http/Middleware/TrustProxies.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

namespace App\Http\Middleware;

use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies = '*'; // allow all proxies for testing

/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_PREFIX |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

重啟過後依然沒有抓到 prefix 的資訊,沒想法的我只能到 Laravel 的 Source Code 裡面尋找答案了。

翻起了 Laravel 的 Source Code 後會發現,Laravel Framework TrustedProxies.php 的內部實作其實仰賴sympony這個框架的實作,經過瘋狂的dump()測試洗禮後,我發現sympony那邊沒有正常的把RequestbaseUrl解析出來。我原先以為是sympony的內容有寫錯的部分,為此還發了一篇 issue 在sympony的 GitHub 上。後來其他人的回覆讓我在確認的時候發現,問題其實不來自於symponyX-Forwarded-Prefix處理的實作,而是來自Laravel Framework尚未支援此項 Header。修改Laravel FrameworkTrustedProxies.php後成功讓RequestbaseUrl正確解析。

節錄自src/Illuminate/Http/Middleware/TrustProxies.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

namespace Illuminate\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class TrustProxies
{
/**
* The trusted proxies for the application.
*
* @var array|string|null
*/
protected $proxies;

/**
* The proxy header mappings.
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PREFIX | Request::HEADER_X_FORWARDED_AWS_ELB;

/**
* Retrieve trusted header name(s), falling back to defaults if config not set.
*
* @return int A bit field of Request::HEADER_*, to set which headers to trust from your proxies.
*/
protected function getTrustedHeaderNames()
{
switch ($this->headers) {
case 'HEADER_X_FORWARDED_AWS_ELB':
case Request::HEADER_X_FORWARDED_AWS_ELB:
return Request::HEADER_X_FORWARDED_AWS_ELB;

case 'HEADER_FORWARDED':
case Request::HEADER_FORWARDED:
return Request::HEADER_FORWARDED;

case 'HEADER_X_FORWARDED_FOR':
case Request::HEADER_X_FORWARDED_FOR:
return Request::HEADER_X_FORWARDED_FOR;

case 'HEADER_X_FORWARDED_HOST':
case Request::HEADER_X_FORWARDED_HOST:
return Request::HEADER_X_FORWARDED_HOST;

case 'HEADER_X_FORWARDED_PORT':
case Request::HEADER_X_FORWARDED_PORT:
return Request::HEADER_X_FORWARDED_PORT;

case 'HEADER_X_FORWARDED_PROTO':
case Request::HEADER_X_FORWARDED_PROTO:
return Request::HEADER_X_FORWARDED_PROTO;

case 'HEADER_X_FORWARDED_PREFIX':
case Request::HEADER_X_FORWARDED_PREFIX:
return Request::HEADER_X_FORWARDED_PREFIX;

default:
return Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PREFIX | Request::HEADER_X_FORWARDED_AWS_ELB;
}

return $this->headers;
}
}

內容其實也只是把缺失的X-Forwarded-Prefix支援加進去而已,但是就解決了baseUrl解析錯誤的問題。這個更新我也順便拿去發 PR 給Laravel Framework,沒多久就成功被 merge 進 master 分支了,沒有意外的話應該會在 Laravel 9 發布的時候看到它。

Inertia Base URL 問題

雖然確定 Request 的baseUrl已經被正確解析,但是問題並沒有解決。我猜測問題可能來自 Inertia 和它的前端路由。稍微翻過以後我先暫時排除前端路由出錯,決定來研究一下 Inertia 到底發生了什麼事,導致路徑被改寫。

仔細研究以後發現問題出在 Inertia 在控制傳回的 json response 時 url 只有考慮request URI,沒有在前面接上baseUrl,才導致最終出來的路徑是錯誤的,於是修正方法就是直接把baseUrl接在request URI前面。

節錄自src/Response.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php

namespace Inertia;

use Closure;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Response as ResponseFactory;
use Illuminate\Support\Traits\Macroable;

class Response implements Responsable
{
use Macroable;

protected $component;
protected $props;
protected $rootView;
protected $version;
protected $viewData = [];

public function toResponse($request)
{
$only = array_filter(explode(',', $request->header('X-Inertia-Partial-Data', '')));

$props = ($only && $request->header('X-Inertia-Partial-Component') === $this->component)
? Arr::only($this->props, $only)
: array_filter($this->props, function ($prop) {
return ! ($prop instanceof LazyProp);
});

array_walk_recursive($props, function (&$prop) use ($request) {
if ($prop instanceof LazyProp) {
$prop = App::call($prop);
}

if ($prop instanceof Closure) {
$prop = App::call($prop);
}

if ($prop instanceof Responsable) {
$prop = $prop->toResponse($request)->getData(true);
}

if ($prop instanceof Arrayable) {
$prop = $prop->toArray();
}
});

foreach ($props as $key => $value) {
if (str_contains($key, '.')) {
data_set($props, $key, $value);
unset($props[$key]);
}
}


// 在url前面串接base url
$page = [
'component' => $this->component,
'props' => $props,
'url' => $request->getBaseUrl().$request->getRequestUri(),
'version' => $this->version,
];

if ($request->header('X-Inertia')) {
return new JsonResponse($page, 200, [
'Vary' => 'Accept',
'X-Inertia' => 'true',
]);
}

return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]);
}
}

這個錯誤我也有發一個 PR 到他們的 GitHub,過了好幾天還沒被審,就先擺著了。

https 被轉址回 http 再被轉回 https?

這個問題導致 XHR 請求會被 CORS policy 擋下來,很直覺的會跑去猜是 reverse proxy 的設定錯誤。於是我很暴力的改了設定。

NGINX 設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server {
listen 80;
listen [::]:80;

server_name _;

location /to/project {
proxy_set_header X-Forwarded-Prefix /to/project;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host domain;
proxy_set_header X-Forwarded-Port 443;
proxy_pass http://localhost:8080/;
}

location /to/project/ {
proxy_set_header X-Forwarded-Prefix /to/project;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host domain;
proxy_set_header X-Forwarded-Port 443;
proxy_pass http://localhost:8080/;
}
}

問題來自於吃到https://domain/to/project這種請求時會先被重新導向到http://domain/to/project/,所以我設兩個相同的 proxy 設定給不同的兩個 url 來解決問題。解決方案有點醜,之後可能會寫成 conf 檔再 include 進來,但是現在會動就好,所以先這樣擺著。

問題至此算是全部結束,剩下一些 xhr 請求位置錯誤的問題,之後等比較有空再來修。

結語

沒想到打工地方的專案加個 reverse proxy 可以衍生出這麼多問題,還讓我發了兩個 PR,其中一個還是世界知名的 php web framework,學到了不少。此外,這學期感覺有點太混了,未來基於現實考量應該會再更認真一些,以上是這次的報告,感謝讀到這裡的所有人。