chundev
日期:2026-04-08 環境:Ubuntu + Node.js 18 + Axios + PM2 + Squid Proxy
部署在有 Squid proxy 的封閉網路環境中,Node.js 應用程式的內網 API 請求莫名失敗,回傳 403 ERR_ACCESS_DENIED。根本原因是三個問題疊加:(1) 系統設了 http_proxy 但沒設 no_proxy,導致內網請求被送到 Squid;(2) axios 底層的 proxy-from-env 不支援 CIDR 格式的 no_proxy;(3) PM2 的環境變數快照機制讓修復無法持久化。
一個 Next.js 應用部署在封閉內網中,需要呼叫同網段內的認證服務(HTTPS API)。同時,這台主機透過 Squid proxy 連到外部網路寄送告警信件。系統在 /etc/environment 設定了 http_proxy 和 https_proxy 指向 Squid,但沒有設定 no_proxy。
某天使用者回報「無法登入」,但應用本身運行正常、資料庫連線正常,從外部完全無法判斷原因。
使用 axios 打內網 HTTPS API 時,收到的回應不是預期的 JSON,而是一個 HTML 頁面:
<body id=ERR_ACCESS_DENIED>
<h1>ERROR</h1>
<h2>The requested URL could not be retrieved</h2>
<p><b>Access Denied.</b></p>
<p>Generated by localhost (squid/6.14)</p>
</body>
HTTP status code: 403
但程式碼裡的 catch 區塊把所有錯誤都吞掉了,只回傳 false:
catch (error) {
console.log(error); // 印在 server log,外部看不到
return false; // 認證服務掛了 = 密碼錯誤,無法區分
}
| 步驟 | 做法 | 結果 | 分析 |
|---|---|---|---|
| ❌ 1 | 直接 curl 目標 IP:port | HTTP 000, timeout |
以為主機掛了 |
| ❌ 2 | 設 no_proxy=192.168.0.0/16(CIDR) |
仍被 Squid 攔截 | axios 不認 CIDR |
| ✅ 3 | nc -zv 測 TCP port |
succeeded |
確認主機活著 |
| ✅ 4 | curl 加 --noproxy '*' |
回 EHOSTUNREACH |
確認是走 proxy 才通(因為 proxy 和目標同網段) |
| ✅ 5 | axios 加 proxy: false |
EHOSTUNREACH |
確認 axios 預設走了 proxy |
| ✅ 6 | no_proxy 改用具體 IP |
200 OK |
解決 |
proxy-from-env 不支援 CIDRAxios 透過依賴鏈 axios → follow-redirects → proxy-from-env 來解析 proxy 環境變數。proxy-from-env 支援的 no_proxy 格式:
| 格式 | 範例 | 支援? |
|---|---|---|
| 完整 hostname | api.internal.com |
✅ |
| 域名後綴 | .internal.com |
✅ |
| 具體 IP | 192.168.1.100 |
✅ |
| 萬用字元 | * |
✅ |
| CIDR | 192.168.0.0/16 |
❌ |
| IP 範圍 | 192.168.1.1-192.168.1.255 |
❌ |
CIDR 格式(如 192.168.0.0/16)會被當成字面字串比對,永遠不會匹配任何實際 IP。
正確寫法:
# ❌ 不會生效
no_proxy=192.168.0.0/16,10.0.0.0/8
# ✅ 逐一列出需要排除的 IP
no_proxy=localhost,127.0.0.1,192.168.160.99,192.168.98.59
# ✅ 如果內網用域名,可以用後綴匹配
no_proxy=localhost,127.0.0.1,.internal.local
注意: cURL、wget、Python requests 等工具對
no_proxyCIDR 的支援各不相同。cURL 7.86+ 支援 CIDR,但 Node.js 生態系普遍不支援。不要假設所有工具的行為一致。
參考:
no_proxy 格式支援的差異比較PM2 在第一次 pm2 start 時會快照所有環境變數,存入 ~/.pm2/dump.pm2。之後的行為:
| 操作 | 環境變數來源 |
|---|---|
pm2 restart app |
dump 快照(不讀當前 shell) |
pm2 restart app --update-env |
當前 shell 環境變數 |
pm2 resurrect(重開機) |
dump 快照 |
pm2 save |
把當前 runtime 的環境變數寫入 dump |
這表示: 修改 /etc/environment 後,即使 SSH 新 session 能讀到新值,PM2 管理的 process 仍然用舊值。重開機後 pm2 resurrect 恢復的也是舊的 dump。
完整的環境變數更新 SOP:
# 1. 修改系統環境變數
sudo vim /etc/environment
# 2. 在當前 shell export(/etc/environment 需要新 session 才生效)
export no_proxy=localhost,127.0.0.1,192.168.160.99
export NO_PROXY=localhost,127.0.0.1,192.168.160.99
# 3. 重啟 PM2 process 並更新快照
pm2 restart all --update-env
pm2 save
# 4. 驗證 dump 內容
cat ~/.pm2/dump.pm2 | python3 -c '
import json, sys
for app in json.load(sys.stdin):
print(f"{app[\"name\"]}: no_proxy={app.get(\"env\",{}).get(\"no_proxy\",\"NOT SET\")}")
'
少了 pm2 save 這一步,重開機就會打回原形。
原始程式碼把所有 HTTP 錯誤(包括 Squid 的 403)都當成「認證失敗」處理:
// ❌ 壞的模式:所有錯誤都變成同一個結果
try {
const response = await axios.post(ideUrl, credentials);
if (response.data?.errorCode !== 0) throw new Error("INVALID");
return true;
} catch (error) {
console.log(error); // 只印在本地,外部看不到
return false; // Squid 403 = IDE 掛了 = 密碼錯 = 全部一樣
}
更好的做法是區分「系統錯誤」和「業務錯誤」:
// ✅ 區分錯誤類型
try {
const response = await axios.post(ideUrl, credentials, { timeout: 10000 });
if (response.data?.errorCode !== 0) {
return { ok: false, reason: "invalid_credentials" };
}
return { ok: true };
} catch (error) {
const code = error.code; // ECONNREFUSED, ECONNABORTED, EHOSTUNREACH...
const status = error.response?.status; // 403, 502...
// 寄出告警信 — 這是系統問題,不是使用者的錯
await sendAlert(`認證服務異常: ${code ?? `HTTP ${status}`}`);
return { ok: false, reason: "system_error", detail: code ?? status };
}
這樣外部至少能收到告警信知道「認證服務掛了」,而不是猜測使用者是不是打錯密碼。
no_proxy 沒有統一標準 — 每個工具(cURL、Node.js、Python)的解析邏輯不同,CIDR 支援尤其不一致。在 Node.js 生態系中,只有具體 IP 和域名後綴是安全的格式--update-env + pm2 save 雙管齊下no_proxy — 只設 http_proxy / https_proxy 而不設 no_proxy,等於所有 HTTP 請求都走 proxy,包括內網的return false,讓問題定位變得極其困難ERR_ACCESS_DENIED (403) 通常是 ACL 規則攔截,不是目標服務的問題。看到 HTML 回應中有 squid 字樣就該往 proxy 方向查no_proxy 支援差異的完整比較